studio-engine 0.47.2 → 0.48.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: 918309978020f15861f65e0a861325bb7ba93046b4d9039aa1aaa1ae6da22c95
4
- data.tar.gz: 682e1529aeca6541d08ab5173028bd407b3538fed1e1b9dd3fb88b70b11e0145
3
+ metadata.gz: fa8ab90a0e1627e7150ed1e96a348bae677c3dc35891ccd2a46f99d857cd74d1
4
+ data.tar.gz: e2a354a9d1d0533a78c63cb19233ae387a4c71f747cd5bf4570fe8d2478979de
5
5
  SHA512:
6
- metadata.gz: 3da6d45e75a8adcbb72b39117e416381b6c6962fe4b831ee2a90d4feddd9b60a4c63b969cc85d442780685d0a0c2916fe423e7dd11c1df392d2fa06a3f9267e4
7
- data.tar.gz: 40e41ee896e9375ebeb9522afb78a9ed481c57c6f15f7a2681a9a51211cf861875a82125bdc52cc6a3b5b9d9820d46e72bd6b2b961822f46ee39367300564755
6
+ metadata.gz: d7def604f1a4c19da8241953cee4bfa8e8047af2a2ae0e0113628aeed9f5d59ab5fa16bfe667e89687144f0f0afb5117e1bcd1f4747ab69db9c5b892e142c820
7
+ data.tar.gz: 7424031597c4168ac6a8172360b7dbc2ee584f0ddc5ac205a155fd11343f7e8f91002a40d3b0865eef0218096234aeaf1617ff2133677aa1e51c9b73d91a8cf3
data/CHANGELOG.md CHANGED
@@ -4,8 +4,212 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ### Fixed
8
+
9
+ - **`Studio.profile_sections`' `if:` gate no longer fails open on a Symbol.**
10
+ `if: :some_predicate` — Rails' own `before_action ..., if: :method_name`
11
+ spelling, and so the most natural thing a host will write — was coerced
12
+ straight to `true`, because a Symbol does not answer `call`. The gate silently
13
+ did nothing, with no signal to the host, in the same permissive direction as
14
+ the bug `if:` was added to fix. Symbols and Strings now name a method on the
15
+ view; a name the view does not answer drops the row rather than rendering it.
16
+ Lambdas and plain booleans are unchanged.
17
+
18
+ ### Added
19
+
20
+ - **`/profile` gains the Google account row.** Shows the linked identity with an
21
+ Unlink control, or a branded Connect button that POSTs to OmniAuth's own
22
+ `/auth/google_oauth2` (the engine does not draw a link route — the middleware
23
+ owns that path). Lifted from turf-monster's `/account` Identities card.
24
+
25
+ New route: `DELETE /profile/google` → `profile_unlink_google_path`.
26
+
27
+ **`Studio::OauthIdentity`** carries the rules, pure and duck-typed like
28
+ `Studio::ProfileImage`: `google_linked?`, `remaining_sign_ins`,
29
+ `unlink_orphans_account?`. It matches **both** provider spellings in the wild —
30
+ `google_oauth2` (the OmniAuth strategy name that lands in `users.provider`) and
31
+ `google` (what `Studio.auth_methods` calls it).
32
+
33
+ **THE ORPHAN GUARD — the reason this is not a straight copy.** turf-monster's
34
+ unlink is an unconditional `update!(provider: nil, uid: nil)`. For an account
35
+ whose only sign-in is Google — blank email so no magic link, no wallet, no
36
+ password — that silently locks someone out of their own account behind a button
37
+ labelled "Unlink". It is safe in turf today only because turf's users happen to
38
+ carry an email, which is a property of that app's **data**, not of its code.
39
+
40
+ The engine refuses instead, and gates on **`Studio.auth_methods`, not merely on
41
+ the column**: an app that has an `email` column but does not offer magic-link
42
+ sign-in cannot use it to get back in, so counting it would be exactly the wrong
43
+ answer. The row disables the button with the reason beside it; the endpoint
44
+ refuses the request independently, because a disabled button is a courtesy and
45
+ anyone can send the `DELETE`.
46
+
47
+ **What counts as a way back in is deliberately narrow**, because a false
48
+ positive here permits an unlink that orphans an account. A password counts only
49
+ via `Studio.password_login_available?` (`auth_method?(:password)` **and** the
50
+ User answering `authenticate`) — turf-monster removed `has_secure_password` and
51
+ kept the column, so its rows carry fossil digests no code can authenticate
52
+ against. A wallet counts only when the host has **explicitly** named its
53
+ signing-wallet column via `Studio.wallet_address_method`; the engine does not
54
+ guess a conventional reader, because turf's `User#solana_address` returns
55
+ `web3 || web2` and only the web3 address can sign in — the web2 one is
56
+ custodial, with no signer. An unconfigured app is treated as having no wallet
57
+ sign-in, which errs toward refusing: the cost is a refusal the operator fixes
58
+ with one config line, against someone locked out of their account.
59
+
60
+ The row declares `requires: %i[provider uid]` **and**
61
+ `if: -> { Studio.auth_method?(:google) }` — the model gate and the app gate are
62
+ different questions, and only the pair is correct. **Every** consumer's users
63
+ table carries `provider` and `uid`, so the model gate alone selected the whole
64
+ fleet; mcritchie-industries has both columns, `auth_methods = %i[magic_link]`,
65
+ and no omniauth gem at all, and would have been handed a "Link Google Account"
66
+ button leading nowhere. The engine's login page already asks the app question
67
+ before drawing this identical button (`app/views/sessions/new.html.erb`).
68
+
69
+ - **`Studio.profile_sections` rows accept `if:`** — an optional callable gating a
70
+ row on an app capability, evaluated at resolve time, called with the view when
71
+ it takes an argument and without when it does not. Distinct from `requires:`,
72
+ which asks whether the user MODEL can serve the row.
73
+
74
+ - **The shared profile page — `/profile`.** The engine now ships the account page
75
+ itself, not just the parts. `Studio::ProfilesController` renders a page of
76
+ declared rows; iteration one ships two, **change your photo** and **change your
77
+ first name**. Every consumer gets it on upgrade with no configuration.
78
+
79
+ **Why this page exists at all.** `components/_user_nav` has always linked the
80
+ username and the avatar to `defined?(account_path) ? account_path : "#"`, and
81
+ only turf-monster draws an account route — so every app that renders the
82
+ ENGINE's copy of that partial shipped a navbar whose avatar and username are
83
+ `href="#"`. A dead link looks exactly like a working one until it is clicked,
84
+ which is why it survived in production. The engine assumed a page every consumer
85
+ was expected to write for itself; now it ships the page.
86
+
87
+ **Which apps that actually is** (host views shadow engine views, so a fork does
88
+ not receive this): **mcritchie-industries and acquisition-studio** render the
89
+ engine partial and had the dead link. **mcritchie-studio** and **turf-monster**
90
+ each ship their own `app/views/components/_user_nav.html.erb` and are unaffected
91
+ either way; **moms-app** forks `layouts/_navbar` and renders no user nav at all.
92
+
93
+ **Why `/profile` and not `/account`.** turf-monster owns `AccountsController`
94
+ and the `account_path` helper. A shared `/account` route would raise
95
+ `Invalid route name, already in use` while turf's own `routes.rb` loads, which
96
+ takes down **every** route in that app — the same collision that forced
97
+ `draw_admin_emails_routes` and `draw_onboarding_routes` to be opt-in. `profile`
98
+ is claimed by none of the five consumers (mcritchie-studio,
99
+ mcritchie-industries, turf-monster, moms-app, acquisition-studio — each checked
100
+ 2026-08-14), so **`Studio.draw_profile_routes` defaults to `true`** and a
101
+ brand-new app is correct on day one. It also buys turf a migration path: its
102
+ `/account` keeps working untouched while its rows move to `/profile` one at a
103
+ time, and `/account` is deleted only once it is empty.
104
+
105
+ | Route | Helper | What it does |
106
+ |---|---|---|
107
+ | `GET /profile` | `profile_path` | The page |
108
+ | `PATCH /profile` | `profile_path` | Scalar fields (today: `first_name`) |
109
+ | `PATCH /profile/avatar` | `profile_avatar_path` | The picture |
110
+
111
+ The avatar has a route of its own **deliberately**: an attachment param
112
+ submitted empty PURGES the attachment, so one form carrying both the name and
113
+ the file would delete someone's photo every time they edited their name.
114
+
115
+ **The photo row is turf-monster's avatar interaction, standardized.** Click the
116
+ picture, a hover cap reads "Update", pick a file, crop it square, and it saves
117
+ immediately — no Save button and no visible file field. It runs on the engine's
118
+ existing `imageUploadHost` factory and shared crop-photo modal, the same
119
+ primitives `/admin/emails` uses.
120
+
121
+ Two changes from turf's copy, both deliberate:
122
+
123
+ - **The click target is a `<button>`, not a `<div>`.** turf's is a `div @click`,
124
+ which no keyboard or screen-reader user can reach — for them the only way to
125
+ change a photo would be a mouse. Same visual; the hover cap also reveals on
126
+ focus.
127
+ - **The crop confirm goes through `onCropConfirmed()`, not `applyCrop()`.** Every
128
+ `imageUploadHost` on a page hears the same window event, so binding
129
+ `applyCrop` directly means a page that later grows a second uploader has both
130
+ hosts save the same crop. `onCropConfirmed` checks the owner token first.
131
+
132
+ **The page brings its own modals.** A row declares `modals: true` and the page
133
+ mounts `studio/modals/_scoped_host` on a `profileModals` store, so the row works
134
+ in an app that renders no shared modal host at all (mcritchie-industries,
135
+ moms-app, acquisition-studio) and is not shadowed in the apps that ship a fork of
136
+ `studio/modals/_host` (mcritchie-studio, turf-monster). **No host app has to
137
+ touch its layout to get a working avatar cropper.** cropper.js loads only when
138
+ a resolved row actually needs it.
139
+
140
+ - **`Studio.profile_sections` — declare the rows, per app.** Same shape as
141
+ `Studio.sidebar_sections`: a static Array or a callable receiving the view, keys
142
+ symbolized, `admin: true` rows gated. Compose against
143
+ `Studio.default_profile_sections` so a later release that adds a standard row
144
+ delivers it to you:
145
+
146
+ ```ruby
147
+ Studio.configure do |config|
148
+ config.profile_sections = ->(view) {
149
+ Studio.default_profile_sections +
150
+ [ { key: :wallet, title: "Identities", partial: "profiles/wallet" } ]
151
+ }
152
+ end
153
+ ```
154
+
155
+ Drop a standard row by key instead of restating the list:
156
+ `config.profile_sections = Studio.default_profile_sections.reject { |s| s[:key] == :avatar }`.
157
+
158
+ **`nil` — the default — means the standard page, NOT a blank one.** That
159
+ distinction is what makes an empty initializer produce a working page.
160
+
161
+ A row may declare `requires:` — an attribute the current user must respond to —
162
+ and is **dropped when this host's model does not have it**. The consuming apps
163
+ genuinely disagree about `users` (mcritchie-industries has eight columns and no
164
+ `first_name`; turf-monster has forty), so a shared page that assumed a column
165
+ would raise `NoMethodError` on every signed-in request there. MI gets a page
166
+ with the photo row and no name row until it installs the standard profile
167
+ columns, at which point the row appears with no code change.
168
+
169
+ - **`Studio::UserProfile` — the display helpers, written once.** A model concern
170
+ supplying `display_name`, `avatar_initials`, `avatar_color` and
171
+ `AVATAR_COLORS`. `components/_avatar` has always called all three and the engine
172
+ has never provided any of them, so all three apps wrote their own — and they had
173
+ drifted (`"anon"` vs `"User"` for the same empty state; two different email-prefix
174
+ casings). `include Studio::UserProfile` and delete your copy. Every method is
175
+ overridable by defining it in the class body.
176
+
177
+ **Adopting changes behavior in mcritchie-industries**, deliberately: a user with
178
+ no name now falls back to `"anon"` rather than `"User"`, and an email-derived
179
+ name is capitalized. The merged chain is
180
+ `username → name → first_name → email prefix → truncated wallet → "anon"`,
181
+ respond_to?-guarded at every link.
182
+
183
+ - **`Studio::ProfileImage` — one allowlist for profile uploads.** `ALLOWED_CONTENT_TYPES`
184
+ (PNG, JPEG, WebP), `MAX_BYTES` (8 MB) and `acceptable?(file)`, lifted from
185
+ turf-monster's `ApplicationController#valid_image?`. It is an allowlist rather
186
+ than a `start_with?("image/")` check because an avatar is attacker-supplied
187
+ bytes served back to other people, and an SVG is a script host wearing an
188
+ image's content type.
189
+
190
+ - **`Studio::FIRST_NAME_MAX_LENGTH`** (40) — one constant for a column now written
191
+ from two surfaces (the onboarding step and `/profile`) and rendered by a third
192
+ (the form's `maxlength`). `Studio::OnboardingController::MAX_FIRST_NAME` now
193
+ reads it instead of defining a second copy.
194
+
7
195
  ### Changed
8
196
 
197
+ - **`components/_user_nav` no longer renders a dead link.** The username and
198
+ avatar point at **a host's own `account_path` where it exists**, at
199
+ `profile_path` otherwise, and render as **plain text** when neither exists.
200
+ The name and the picture still render in that last case — only the link is
201
+ dropped. Previously both were `href="#"`.
202
+
203
+ **The host's page wins deliberately** — the engine does not repoint an app that
204
+ already has an account page of its own. No consumer exercises that branch today
205
+ (turf-monster is the only app with an `account_path`, and it forks this partial
206
+ anyway), so it is a rule for the apps that adopt `/profile` later rather than a
207
+ behavior change now.
208
+
209
+ So: **no consumer's navbar destination changes on this upgrade.**
210
+ mcritchie-industries and acquisition-studio gain a working link where they had
211
+ `href="#"`; every other consumer forks the partial and is untouched.
212
+
9
213
  - **No pre-registered email seeds a logo — `magic_link` was the last one, and it
10
214
  seeded the Studio wordmark onto the SIGN-IN email.** `STANDARD`'s `magic_link`
11
215
  entry carried `logo: "emails/logo-horizontal.png"`, and
@@ -23,7 +23,11 @@ module Studio
23
23
  # Both actions are for the freshly signed-in user, so authentication is the
24
24
  # host's default require_authentication — no skip_before_action here.
25
25
 
26
- MAX_FIRST_NAME = 40
26
+ # The shared cap (Studio::FIRST_NAME_MAX_LENGTH). Kept under the old name
27
+ # here because it is referenced from outside this class; what changed is that
28
+ # it is no longer a SECOND definition of the same number. /profile writes the
29
+ # same column and now reads the same constant.
30
+ MAX_FIRST_NAME = Studio::FIRST_NAME_MAX_LENGTH
27
31
 
28
32
  # POST /onboarding/first_name
29
33
  #
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # /profile — the shared account page every Studio app gets.
5
+ #
6
+ # A plain host-inherited controller in the same shape as StyleController and
7
+ # Studio::EmailsController: its view is a bare content wrapper, so it renders
8
+ # inside each host's application layout and picks up that app's navbar, theme
9
+ # and flash. The engine supplies the page; the app supplies the frame.
10
+ #
11
+ # WHY /profile AND NOT /account (2026-08-14, operator's call). turf-monster owns
12
+ # `AccountsController` and the `account_path` helper today. Drawing a shared
13
+ # /account route would raise `Invalid route name, already in use` while turf's
14
+ # own routes.rb loads, which takes down EVERY route in that app — the exact
15
+ # failure that forced draw_admin_emails_routes and draw_onboarding_routes to be
16
+ # opt-in. `profile` is unclaimed in all five consumers, so this page can be
17
+ # drawn by default, which is what makes a brand-new app correct on day one.
18
+ #
19
+ # It also buys the migration path: turf keeps /account working untouched while
20
+ # its rows move to /profile one at a time, and /account is deleted only once it
21
+ # is empty. Two pages briefly coexisting is the point, not an accident.
22
+ #
23
+ # WHAT ROWS RENDER is Studio.profile_sections — see lib/studio/profile_sections.rb.
24
+ # Iteration one ships two: the avatar and the first name.
25
+ class ProfilesController < ::ApplicationController
26
+ # The host's own guard, supplied by Studio::ErrorHandling and format-aware
27
+ # (HTML redirects to login; JSON gets a clean 401 rather than a 406).
28
+ before_action :require_authentication
29
+
30
+ # THE shared cap, not a copy of it — see Studio::FIRST_NAME_MAX_LENGTH. The
31
+ # onboarding step writes this same column and reads this same constant, so
32
+ # the two surfaces cannot drift apart.
33
+ MAX_FIRST_NAME = Studio::FIRST_NAME_MAX_LENGTH
34
+
35
+ def show
36
+ @profile_sections = Studio.profile_sections_for(view_context)
37
+ end
38
+
39
+ # PATCH /profile — the scalar fields. Today that is the first name.
40
+ def update
41
+ return unsupported(:first_name) unless serves?(:first_name)
42
+
43
+ value = normalized_first_name
44
+
45
+ if value.blank?
46
+ return redirect_to profile_path, alert: "Enter your first name.", status: :see_other
47
+ end
48
+
49
+ rescue_and_log(target: current_user) do
50
+ attrs = { first_name: value }
51
+ # Backfill `name` when it is blank so the display-name chain has
52
+ # something better than an email prefix to show. Same rule as the
53
+ # onboarding step, which writes this column from the other direction.
54
+ attrs[:name] = value if current_user.respond_to?(:name) && current_user.name.blank?
55
+
56
+ if current_user.update(attrs)
57
+ # Read back rather than trusting the write. A host whose before_save
58
+ # DERIVES first_name from name (turf-monster's set_name_parts does
59
+ # exactly that) would silently discard the value, and a flash saying
60
+ # "Saved" over a discarded write is worse than a plain failure.
61
+ # Reporting what actually persisted keeps the page honest on a host the
62
+ # engine has not met yet.
63
+ persisted = current_user.reload.first_name.to_s
64
+
65
+ if persisted == value
66
+ redirect_to profile_path, notice: "Name updated."
67
+ else
68
+ redirect_to profile_path,
69
+ alert: "This app derives your name from another field — it saved as #{persisted.presence || "blank"}.",
70
+ status: :see_other
71
+ end
72
+ else
73
+ redirect_to profile_path,
74
+ alert: current_user.errors.full_messages.to_sentence.presence || "Could not save that name.",
75
+ status: :see_other
76
+ end
77
+ end
78
+ end
79
+
80
+ # PATCH /profile/avatar — the picture, on its own route.
81
+ #
82
+ # SEPARATE FROM #update deliberately: an attachment param submitted empty
83
+ # PURGES the attachment, so a combined form that carried both would delete
84
+ # someone's avatar every time they edited their name. turf-monster learned
85
+ # this and branched inside its own #update; a separate route is the same
86
+ # lesson expressed so the trap cannot be reintroduced.
87
+ def avatar
88
+ return unsupported(:avatar) unless serves?(:avatar)
89
+
90
+ file = params.dig(:profile, :avatar)
91
+
92
+ if file.blank?
93
+ return redirect_to profile_path, alert: "Choose an image first.", status: :see_other
94
+ end
95
+
96
+ unless Studio::ProfileImage.acceptable?(file)
97
+ return redirect_to profile_path, alert: Studio::ProfileImage::MESSAGE, status: :see_other
98
+ end
99
+
100
+ rescue_and_log(target: current_user) do
101
+ current_user.avatar.attach(file)
102
+ redirect_to profile_path, notice: "Photo updated."
103
+ end
104
+ end
105
+
106
+ # DELETE /profile/google — drop the linked Google identity.
107
+ #
108
+ # REFUSES WHEN IT WOULD ORPHAN THE ACCOUNT. turf-monster's version is an
109
+ # unconditional `update!(provider: nil, uid: nil)`; for an account whose only
110
+ # sign-in is Google (no email, so no magic link; no wallet; no password) that
111
+ # locks someone out of their own account behind a button labelled "Unlink".
112
+ # It is safe in turf only because turf's users happen to carry an email —
113
+ # a property of that app's data, not of the code.
114
+ #
115
+ # Studio::OauthIdentity gates on Studio.auth_methods, not merely on the
116
+ # column: an app with an email column that does not offer magic-link sign-in
117
+ # cannot use it to get back in.
118
+ def unlink_google
119
+ return unsupported(:google_account) unless serves?(:provider) && serves?(:uid)
120
+
121
+ unless Studio::OauthIdentity.google_linked?(current_user)
122
+ return redirect_to profile_path, alert: "No Google account is linked.", status: :see_other
123
+ end
124
+
125
+ if Studio::OauthIdentity.unlink_orphans_account?(current_user)
126
+ return redirect_to profile_path, status: :see_other,
127
+ alert: "Google is the only way to sign in to this account. " \
128
+ "Add an email address first, then unlink."
129
+ end
130
+
131
+ rescue_and_log(target: current_user) do
132
+ current_user.update!(provider: nil, uid: nil)
133
+ redirect_to profile_path, notice: "Google account unlinked."
134
+ end
135
+ end
136
+
137
+ private
138
+
139
+ # Can this host's user model serve this field?
140
+ #
141
+ # A HIDDEN ROW IS NOT A GUARD. Studio::ProfileSections drops a row the host
142
+ # cannot serve, so nobody SEES a first-name form in an app without the
143
+ # column — but the endpoint stays open to anyone who posts to it, and
144
+ # rescue_and_log RE-RAISES, so an unguarded write is a 500 plus an ErrorLog
145
+ # row. That is not hypothetical: three of the five consumers
146
+ # (mcritchie-industries, moms-app, acquisition-studio) have an avatar
147
+ # attachment and no first_name column right now.
148
+ #
149
+ # The endpoint asks the SAME question the row does, through the SAME method,
150
+ # so the two cannot drift into disagreeing about what this app supports.
151
+ def serves?(attribute)
152
+ Studio::ProfileSections.served_by?(current_user, attribute)
153
+ end
154
+
155
+ # Land the person back on a page that works rather than on a bare 404. This
156
+ # is unreachable through the UI — the row that posts here is not rendered —
157
+ # so the wording is for whoever is poking at the endpoint directly.
158
+ def unsupported(attribute)
159
+ redirect_to profile_path,
160
+ alert: "This app has no #{attribute.to_s.tr("_", " ")} to change.",
161
+ status: :see_other
162
+ end
163
+
164
+ # Collapse runs of whitespace and cap the length. Done here rather than in a
165
+ # model validation because the engine does not own the host's User class.
166
+ def normalized_first_name
167
+ params.dig(:profile, :first_name).to_s.strip.gsub(/\s+/, " ")[0, MAX_FIRST_NAME].to_s
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,111 @@
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
@@ -40,6 +40,33 @@
40
40
  end
41
41
  end %>
42
42
 
43
+ <%# Where the username and the avatar point — resolved ONCE, used by both.
44
+
45
+ A HOST'S OWN account_path WINS: an app that already has an account page keeps
46
+ it rather than being repointed at a thinner /profile by a routine dependency
47
+ bump. An app adopting /profile flips over by DELETING its account route, so
48
+ the last step of the migration is the deliberate one.
49
+
50
+ NO CONSUMER EXERCISES THAT BRANCH TODAY, and the comment here said otherwise
51
+ until 2026-08-14. turf-monster is the only app with an account_path, and it
52
+ ships its own app/views/components/_user_nav.html.erb — a host view SHADOWS
53
+ the engine's in this non-isolated engine, so turf never renders THIS file.
54
+ The rule is for apps that adopt /profile later.
55
+
56
+ nil when NEITHER exists, and then the name and avatar render as plain text.
57
+ They used to render `href="#"`, which was not a graceful degradation but a
58
+ dead link. Who that actually reached: mcritchie-industries and
59
+ acquisition-studio — the consumers rendering this partial rather than a fork
60
+ of it. A link that goes nowhere looks identical to one that works until it is
61
+ clicked, which is why nobody reported it.
62
+ %>
63
+ <% studio_nav_destination =
64
+ if defined?(account_path)
65
+ account_path
66
+ elsif defined?(profile_path)
67
+ profile_path
68
+ end %>
69
+
43
70
  <div class="flex gap-2">
44
71
  <% if logged_in? %>
45
72
  <%# Left column: Div 1 + Div 2 stacked %>
@@ -61,9 +88,11 @@
61
88
  <%= render "components/admin_dropdown" unless respond_to?(:studio_sidebar_replaces_admin_menu?) && studio_sidebar_replaces_admin_menu? %>
62
89
  <%= render "components/theme_toggle_morph" %>
63
90
  </div>
64
- <% account_link = defined?(account_path) ? account_path : "#" %>
65
- <%= link_to account_link, class: "text-heading font-semibold hover:text-primary transition text-base leading-none truncate text-right" do %>
66
- <%= current_user.display_name %>
91
+ <% nav_name_classes = "text-heading font-semibold hover:text-primary transition text-base leading-none truncate text-right" %>
92
+ <% if studio_nav_destination %>
93
+ <%= link_to current_user.display_name, studio_nav_destination, class: nav_name_classes %>
94
+ <% else %>
95
+ <span class="<%= nav_name_classes %>"><%= current_user.display_name %></span>
67
96
  <% end %>
68
97
  </div>
69
98
  <% div2_content = studio_nav_slot.call(div2_slot, div2_html) %>
@@ -168,9 +197,14 @@
168
197
  <% end %>
169
198
  </div>
170
199
  <%# Div 3 (Avatar): spans both rows %>
171
- <% account_link = defined?(account_path) ? account_path : "#" %>
172
- <%= link_to account_link, class: "hover:opacity-80 transition flex-shrink-0 flex items-center", "x-bind:style": "$store.devMode && 'background: lightgreen'" do %>
173
- <%= render "components/avatar", user: current_user, size: "nav" %>
200
+ <% if studio_nav_destination %>
201
+ <%= link_to studio_nav_destination, class: "hover:opacity-80 transition flex-shrink-0 flex items-center", "x-bind:style": "$store.devMode && 'background: lightgreen'" do %>
202
+ <%= render "components/avatar", user: current_user, size: "nav" %>
203
+ <% end %>
204
+ <% else %>
205
+ <span class="flex-shrink-0 flex items-center" x-bind:style="$store.devMode && 'background: lightgreen'">
206
+ <%= render "components/avatar", user: current_user, size: "nav" %>
207
+ </span>
174
208
  <% end %>
175
209
  <% else %>
176
210
  <%= render "components/admin_dropdown" unless respond_to?(:studio_sidebar_replaces_admin_menu?) && studio_sidebar_replaces_admin_menu? %>