studio-engine 0.78.0 → 0.80.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: 12e1056052d4d49cd70e0ca81e8a39fc4f1c370166d73ea319315337fc648f5f
4
- data.tar.gz: 0d94f90bff68c27a6c86d07ad53904b330a3717121f5f944244d0153a2e53a63
3
+ metadata.gz: 7b9678d8f3c7e2fbb15531909b721ff80ccd9e933d15c283e0c7fa1861137b11
4
+ data.tar.gz: c495551acf3d80d22f1c7760d65cabb0f949021680f94f152514e409c117400a
5
5
  SHA512:
6
- metadata.gz: cb929bb288de471829b57f90cfbf01fff7d4e51d68607da51b32df832ad4946662a0617a85002ec6244c23fb3d3d17db069528b090690378ba68a7f53669141a
7
- data.tar.gz: bb9eb9c389d8d89887ba72b0bff91b080944b501c3564aed06b26b317f013445f6fd9636d327ba20e70cfd7e4d3065928b6d091eed8c22160e6b819de69825ba
6
+ metadata.gz: 9c28aa14a0b3a28ca0dd876fc0835431f66588159ec3d11e79f6319f9350be50e4a49d4e6891253334a07f3a0c49db0974e74819b2d2d6f40a17460f5b8e68f9
7
+ data.tar.gz: 1221cfc40625adeda5fda0293de168969e95d50e43ecb01f2dde1c21c2d0b0f41807b10118f63f291ca74c094f7b1eec1ae23dc4b107be6e6543de8c5b2274ca
data/CHANGELOG.md CHANGED
@@ -4,6 +4,46 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.80.0 — 2026-09-30
8
+
9
+ ### Added
10
+
11
+ - **`Studio.navbar_user_name` and `Studio.sign_in_label` set the navbar's two
12
+ identity words.** `navbar_user_name` takes a method name (`:player_name`) or a
13
+ callable `->(user, view)` and replaces `display_name` in the signed-in user
14
+ nav; a blank answer or a raise falls back to `display_name`, and a raise is
15
+ reported once rather than 500ing the layout. `sign_in_label` (default
16
+ `"Log in"`) labels the signed-out button in `layouts/_navbar` and
17
+ `components/_user_nav`. An app that sets neither renders the same navbar bytes
18
+ as before. See the README, *Navbar identity*.
19
+
20
+ ## 0.79.0 — 2026-09-30
21
+
22
+ ### Changed
23
+
24
+ - **The signed-in user nav fits a phone.** `components/_user_nav` draws its theme
25
+ toggle from `md` up only (the navbar's phone row already has one, so phones
26
+ showed two), and the name and avatar are now ONE link to the account page, one
27
+ tab stop, where they were two links to the same place. Below `md` the avatar
28
+ stands alone and the name is screen-reader text. The desktop sidebar button
29
+ passed as `extra_icons_html`, the admin menu, the account destination, the
30
+ second row and the banners are unchanged. From `md` up the name now sits beside
31
+ the avatar, centred on it, rather than on the top row. Apps that shadow the
32
+ partial (mcritchie-studio, turf-monster, cyvasse) are unaffected until they
33
+ drop their copy.
34
+ - **Default button fills clear WCAG AA under their white label.** The default
35
+ success green is `#367E3A` (5.0:1 under white), was `#4BAF50` (2.78:1); a
36
+ configured success colour paints as before. While an app leaves its primary
37
+ at the engine default `#8E82FE` (3.1:1), the resolver emits `--color-cta-fill`
38
+ (`#7268CB`), which `btn-primary`, `btn-outline`'s hover and the user nav's
39
+ level bar paint; `--color-cta` stays the violet, because apps also paint it
40
+ as text on dark surfaces. A configured primary emits no fill, so those fall
41
+ back to the app's own `--color-cta` as before. `--color-cta-hover` is held to
42
+ AA too, unchanged wherever `darken(primary, 0.30)` already passes. Engine views
43
+ that painted text or icons with the raw `--color-success` now read
44
+ `--color-success-ink` (the darker default green is 3.49:1 as text on the dark
45
+ page).
46
+
7
47
  ## 0.78.0 — 2026-09-29
8
48
 
9
49
  ### Added
data/README.md CHANGED
@@ -611,6 +611,36 @@ end
611
611
  - A bad entry raises `Studio::NavbarLinks::InvalidLink` naming its index: at
612
612
  assignment for a static Array, at render for a callable.
613
613
 
614
+ ### Navbar identity — `Studio.navbar_user_name` and `Studio.sign_in_label`
615
+
616
+ Two words the navbar says about the viewer. Both defaults render the navbar
617
+ byte-identical to before, so an app that sets neither sees no change. Rules:
618
+ `lib/studio/navbar_identity.rb`.
619
+
620
+ ```ruby
621
+ Studio.configure do |config|
622
+ # The signed-in name in components/_user_nav. nil (the default) is display_name.
623
+ config.navbar_user_name = :player_name # a method on the user
624
+ # config.navbar_user_name = ->(user, view) { user.player_name } # or a callable
625
+
626
+ # The signed-out button in layouts/_navbar and components/_user_nav.
627
+ config.sign_in_label = "Sign in" # default "Log in"
628
+ end
629
+ ```
630
+
631
+ - `navbar_user_name` is `nil`, a method name (Symbol or String), or a callable.
632
+ A two-argument callable gets `(user, view)`; a one-argument callable gets the
633
+ user alone.
634
+ - The name is always escaped, even when the method or callable returns an
635
+ `html_safe` string.
636
+ - A method or callable that answers blank falls back to `display_name`. One
637
+ that raises also falls back, and the error is reported once per process per
638
+ error class (to `ErrorLog` when the app has it, else the Rails log), so a
639
+ broken setting never 500s the layout.
640
+ - The engine has no I18n catalogue, so the label is plain config, not a locale
641
+ key. A blank or non-String label, or a `navbar_user_name` of any other type,
642
+ raises `Studio::NavbarIdentity::InvalidConfig` at assignment.
643
+
614
644
  ### User nav slots
615
645
 
616
646
  `components/_user_nav.html.erb` renders the right-side navbar user section.
@@ -230,9 +230,12 @@ body.modal-open .studio-app-banner {
230
230
  @utility with unlayered overrides. Defaults resolve to the same white as
231
231
  before (a literal #fff — the engine's both-modes guard bars raw
232
232
  --color-white references, which only exist in Tailwind's compiled theme). */
233
+ /* The fill is --color-cta-fill when the resolver emits one — only while the
234
+ app leaves its primary at the engine default, whose own hex is 3.1:1 under
235
+ white — and otherwise the app's --color-cta, as it always was. */
233
236
  @utility btn-primary {
234
237
  color: var(--btn-primary-fg, #fff);
235
- background-color: var(--color-cta);
238
+ background-color: var(--color-cta-fill, var(--color-cta));
236
239
  &:hover {
237
240
  color: var(--btn-primary-fg-hover, var(--btn-primary-fg, #fff));
238
241
  background-color: var(--color-cta-hover);
@@ -258,7 +261,7 @@ body.modal-open .studio-app-banner {
258
261
  border-color: var(--color-cta);
259
262
  &:hover {
260
263
  @apply text-white;
261
- background-color: var(--color-cta);
264
+ background-color: var(--color-cta-fill, var(--color-cta));
262
265
  }
263
266
  }
264
267
 
@@ -11,6 +11,10 @@
11
11
  address + level progress bar) entirely
12
12
  (e.g. a seeds bar)
13
13
 
14
+ Config, not locals: the signed-in name is Studio.navbar_user_name
15
+ (default display_name) and the signed-out button reads
16
+ Studio.sign_in_label (default "Log in"); lib/studio/navbar_identity.rb.
17
+
14
18
  Other locals:
15
19
  show_logout_link — boolean, show explicit "Log out" link in the
16
20
  second row (Studio uses this)
@@ -40,7 +44,7 @@
40
44
  end
41
45
  end %>
42
46
 
43
- <%# Where the username and the avatar point — resolved ONCE, used by both.
47
+ <%# Where the account link (the username and the avatar, one link) points.
44
48
 
45
49
  A HOST'S OWN account_path WINS: an app that already has an account page keeps
46
50
  it rather than being repointed at a thinner /profile by a routine dependency
@@ -69,16 +73,14 @@
69
73
 
70
74
  <div class="flex gap-2">
71
75
  <% if logged_in? %>
72
- <%# Left column: Div 1 + Div 2 stacked %>
73
- <%# min-w-0 belongs HERE, on the flex item of the outer row. A flex item
74
- defaults to min-width auto, so without it this column refuses to
75
- shrink below its content and the username overflows instead of
76
- truncating. Div 1 below is a block child of this column, not a flex
77
- item of the outer row, so min-w-0 there has no effect on shrinking.
78
- The username link truncates on its own: overflow-hidden gives it an
79
- automatic minimum size of zero inside Div 1. %>
76
+ <%# Left column: Div 1 + Div 2 stacked.
77
+ min-w-0 belongs HERE, on the flex item of the nav root: a flex item
78
+ defaults to min-width auto, and without it this column refuses to
79
+ shrink below balance + icons. On a 320px phone with a balance the
80
+ reserved column is 147px, and a column that will not give pushed the
81
+ avatar 17px past the screen edge (e2e/header_phone_width.spec.js). %>
80
82
  <div class="flex-1 min-w-0">
81
- <%# Div 1 (Bigger): balance + icons + username. %>
83
+ <%# Div 1 (Bigger): balance + icons. %>
82
84
  <div class="flex items-center gap-2 leading-none" :style="'padding: 0 6px;' + ($store.devMode ? 'background: cornflowerblue;' : '')">
83
85
  <%= studio_nav_slot.call(balance_slot, balance_html) %>
84
86
  <div class="flex items-center gap-2 ml-auto self-end">
@@ -86,14 +88,11 @@
86
88
  <%# Skipped when the link sidebar carries the admin menu — two cog
87
89
  glyphs side by side read as a double gear. %>
88
90
  <%= render "components/admin_dropdown" unless respond_to?(:studio_sidebar_replaces_admin_menu?) && studio_sidebar_replaces_admin_menu? %>
89
- <%= render "components/theme_toggle_morph" %>
91
+ <%# Desktop only. Below md the engine navbar's phone row draws the
92
+ theme toggle, so a second one here put two on every phone. The
93
+ signed-out bar has always done it this way (layouts/_navbar). %>
94
+ <span class="hidden md:flex" data-nav-theme-toggle="desktop"><%= render "components/theme_toggle_morph" %></span>
90
95
  </div>
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>
96
- <% end %>
97
96
  </div>
98
97
  <% div2_content = studio_nav_slot.call(div2_slot, div2_html) %>
99
98
  <% server_level = current_user.respond_to?(:level) && current_user.level.present? ? current_user.level : nil %>
@@ -154,7 +153,7 @@
154
153
  @navbar-seeds-update.window="handleSeedsUpdate($event.detail)"
155
154
  class="relative mt-0.5" style="height: 14px;">
156
155
  <%# Fill bar %>
157
- <div class="absolute inset-y-0 left-0 rounded z-0" style="background: var(--color-cta); transition: width 1.2s cubic-bezier(0.16, 1, 0.3, 1);" :style="{ width: barWidth + '%' }"></div>
156
+ <div class="absolute inset-y-0 left-0 rounded z-0" style="background: var(--color-cta-fill, var(--color-cta)); transition: width 1.2s cubic-bezier(0.16, 1, 0.3, 1);" :style="{ width: barWidth + '%' }"></div>
158
157
  <%# Text layer 1: muted (visible outside bar) %>
159
158
  <div class="absolute inset-0 flex items-center justify-between px-1.5 font-mono text-muted z-[1] nav-bar-text">
160
159
  <span>
@@ -196,21 +195,35 @@
196
195
  </style>
197
196
  <% end %>
198
197
  </div>
199
- <%# Div 3 (Avatar): spans both rows %>
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'">
198
+ <%# Div 3 (Account): the name and the avatar are ONE link to the account
199
+ page, one tab stop — they used to be two links to the same place.
200
+ Below md the name is screen-reader text and the avatar stands alone,
201
+ so a phone never shows the name truncated to "Guest 55…"; from md up
202
+ it shows beside the avatar and truncates when the row is tight.
203
+
204
+ The link never shrinks (the avatar must stay on screen); the icon
205
+ column beside it is what gives way. So the name cannot truncate by
206
+ flex shrinking: from md up its box carries a max width instead, and
207
+ the name ellipsizes inside it. The avatar is aria-hidden: the name is
208
+ the link's accessible name. %>
209
+ <% account_classes = "group flex items-center gap-2 flex-shrink-0" %>
210
+ <% account_inner = capture do %>
211
+ <span class="sr-only md:not-sr-only md:max-w-40"><span class="block truncate text-right text-heading font-semibold text-base leading-none transition<%= " group-hover:text-primary" if studio_nav_destination %>" data-nav-name><%= Studio.navbar_user_name_for(current_user, self) %></span></span>
212
+ <span class="flex-shrink-0 flex items-center" aria-hidden="true">
206
213
  <%= render "components/avatar", user: current_user, size: "nav" %>
207
214
  </span>
208
215
  <% end %>
216
+ <% if studio_nav_destination %>
217
+ <%= link_to account_inner, studio_nav_destination, class: "#{account_classes} hover:opacity-80 transition", "data-nav-account": "", "x-bind:style": "$store.devMode && 'background: lightgreen'" %>
218
+ <% else %>
219
+ <span class="<%= account_classes %>" data-nav-account x-bind:style="$store.devMode && 'background: lightgreen'"><%= account_inner %></span>
220
+ <% end %>
209
221
  <% else %>
210
222
  <%= render "components/admin_dropdown" unless respond_to?(:studio_sidebar_replaces_admin_menu?) && studio_sidebar_replaces_admin_menu? %>
211
- <%= render "components/theme_toggle_morph" %>
223
+ <%# Desktop only, as above: the phone row carries the toggle below md. %>
224
+ <span class="hidden md:flex" data-nav-theme-toggle="desktop"><%= render "components/theme_toggle_morph" %></span>
212
225
  <div class="flex items-center gap-3 ml-auto">
213
- <%= link_to "Log in", login_path, class: "text-heading hover:text-primary text-sm font-semibold transition" %>
226
+ <%= link_to Studio.sign_in_label, login_path, class: "text-heading hover:text-primary text-sm font-semibold transition" %>
214
227
  <%= link_to "Sign up", signup_path, class: "btn btn-primary btn-sm" %>
215
228
  </div>
216
229
  <% end %>
@@ -162,7 +162,7 @@
162
162
  <div class="flex items-center justify-end gap-3">
163
163
  <%= sidebar_trigger_desktop if sidebar_active %>
164
164
  <span class="hidden md:flex"><%= render "components/theme_toggle_morph" %></span>
165
- <%= link_to "Log in", login_path, class: "btn btn-primary" %>
165
+ <%= link_to Studio.sign_in_label, login_path, class: "btn btn-primary" %>
166
166
  </div>
167
167
  <% end %>
168
168
  </div>
@@ -121,7 +121,7 @@
121
121
  </template>
122
122
  <template x-if="!toast.image">
123
123
  <div class="w-10 h-10 rounded-full flex items-center justify-center bg-inset shrink-0" style="margin-top: 2px;">
124
- <svg x-show="toast.type === 'notice'" xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" style="color: var(--color-success)" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
124
+ <svg x-show="toast.type === 'notice'" xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" style="color: var(--color-success-ink)" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
125
125
  <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z" />
126
126
  </svg>
127
127
  <svg x-show="toast.type !== 'notice'" xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" style="color: var(--color-danger)" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
@@ -226,7 +226,7 @@
226
226
  x-transition:leave-end="opacity-0 translate-x-8"
227
227
  class="bg-surface border rounded-lg px-4 py-2.5 text-sm font-medium shadow-lg backdrop-blur-sm"
228
228
  :style="toast.type === 'success'
229
- ? 'border-color: var(--color-success); color: var(--color-success)'
229
+ ? 'border-color: var(--color-success); color: var(--color-success-ink)'
230
230
  : 'border-color: var(--color-danger); color: var(--color-danger)'">
231
231
  <span x-text="toast.message"></span>
232
232
  </div>
@@ -58,7 +58,7 @@
58
58
  <div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
59
59
  <span x-show="status === 'loading'" x-cloak class="spinner text-secondary" aria-hidden="true"></span>
60
60
  <svg x-show="status === 'valid'" x-cloak xmlns="http://www.w3.org/2000/svg" class="w-5 h-5"
61
- style="color: var(--color-success)" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true">
61
+ style="color: var(--color-success-ink)" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true">
62
62
  <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z" />
63
63
  </svg>
64
64
  <svg x-show="status === 'invalid'" x-cloak xmlns="http://www.w3.org/2000/svg" class="w-5 h-5"
@@ -95,7 +95,7 @@
95
95
  <svg x-show="!copiedRef" class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24" aria-hidden="true">
96
96
  <path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
97
97
  </svg>
98
- <svg x-show="copiedRef" x-cloak class="w-3 h-3 shrink-0" style="color: var(--color-success)" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24" aria-hidden="true">
98
+ <svg x-show="copiedRef" x-cloak class="w-3 h-3 shrink-0" style="color: var(--color-success-ink)" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24" aria-hidden="true">
99
99
  <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
100
100
  </svg>
101
101
  <span class="sr-only" x-text="copiedRef ? 'Copied' : ''"></span>
@@ -868,7 +868,7 @@
868
868
  <div class="flex flex-wrap items-center gap-3">
869
869
  <h3 class="text-xl font-bold text-heading">Profile Leveling</h3>
870
870
  <% if leveling_on %>
871
- <span class="badge" style="color: var(--color-success); border-color: var(--color-success)">leveling on</span>
871
+ <span class="badge" style="color: var(--color-success-ink); border-color: var(--color-success)">leveling on</span>
872
872
  <% else %>
873
873
  <span class="badge" style="color: var(--color-warning); border-color: var(--color-warning)">leveling off</span>
874
874
  <% end %>
@@ -958,7 +958,7 @@
958
958
  glow_when: ds_glow.call("join-newsletter", celebrate: true) } do %>
959
959
  <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
960
960
  <div class="mx-auto w-7 h-7 rounded-full flex items-center justify-center" style="background: color-mix(in srgb, var(--color-success) 15%, transparent)">
961
- <svg class="w-4 h-4" style="color: var(--color-success)" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
961
+ <svg class="w-4 h-4" style="color: var(--color-success-ink)" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
962
962
  </div>
963
963
  <div x-show="opts.leveling" class="flex gap-1">
964
964
  <span class="flex-1 h-2 rounded" style="background: var(--color-cta)"></span>
@@ -1022,7 +1022,7 @@
1022
1022
  <div class="flex flex-wrap items-center gap-3">
1023
1023
  <h3 class="text-xl font-bold text-heading">Web3</h3>
1024
1024
  <% if web3_on %>
1025
- <span class="badge" style="color: var(--color-success); border-color: var(--color-success)">enabled on this app</span>
1025
+ <span class="badge" style="color: var(--color-success-ink); border-color: var(--color-success)">enabled on this app</span>
1026
1026
  <% else %>
1027
1027
  <span class="badge" style="color: var(--color-warning); border-color: var(--color-warning)">disabled on this app</span>
1028
1028
  <% end %>
@@ -1192,7 +1192,7 @@
1192
1192
  <div class="flex flex-wrap items-center gap-3">
1193
1193
  <h3 class="text-xl font-bold text-heading">Contest entry &amp; eligibility</h3>
1194
1194
  <% if age_gate_on %>
1195
- <span class="badge" style="color: var(--color-success); border-color: var(--color-success)">age gate on</span>
1195
+ <span class="badge" style="color: var(--color-success-ink); border-color: var(--color-success)">age gate on</span>
1196
1196
  <% else %>
1197
1197
  <span class="badge" style="color: var(--color-warning); border-color: var(--color-warning)">age gate off</span>
1198
1198
  <% end %>
@@ -1312,7 +1312,7 @@
1312
1312
  open_expr: "$store.dsModals.open('ds-success')" } do %>
1313
1313
  <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
1314
1314
  <div class="mx-auto w-7 h-7 rounded-full flex items-center justify-center" style="background: color-mix(in srgb, var(--color-success) 15%, transparent)">
1315
- <svg class="w-4 h-4" style="color: var(--color-success)" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
1315
+ <svg class="w-4 h-4" style="color: var(--color-success-ink)" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
1316
1316
  </div>
1317
1317
  <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
1318
1318
  <span class="block h-4 w-full rounded" style="background: var(--color-cta)"></span>
@@ -1441,7 +1441,7 @@
1441
1441
  <div class="flex flex-wrap items-center gap-3">
1442
1442
  <h3 class="text-xl font-bold text-heading">Rewards</h3>
1443
1443
  <% if leveling_on %>
1444
- <span class="badge" style="color: var(--color-success); border-color: var(--color-success)">enabled on this app</span>
1444
+ <span class="badge" style="color: var(--color-success-ink); border-color: var(--color-success)">enabled on this app</span>
1445
1445
  <% else %>
1446
1446
  <span class="badge" style="color: var(--color-warning); border-color: var(--color-warning)">disabled on this app</span>
1447
1447
  <% end %>
@@ -85,7 +85,7 @@
85
85
  <%= render "studio/board_assets" %>
86
86
  <div class="space-y-3">
87
87
  <div class="flex items-center gap-2">
88
- <span class="badge" style="color: var(--color-success); border-color: var(--color-success)">Live</span>
88
+ <span class="badge" style="color: var(--color-success-ink); border-color: var(--color-success)">Live</span>
89
89
  <p class="label-upper"><code class="font-mono text-2xs">studio/board/board</code> &middot; demo &mdash; drag a card between columns</p>
90
90
  </div>
91
91
  <%= render "studio/board/board",
@@ -268,7 +268,7 @@
268
268
  <div class="grid gap-4 sm:grid-cols-2">
269
269
  <div class="card p-5 space-y-2">
270
270
  <div class="flex items-center gap-2">
271
- <span class="badge" style="color: var(--color-success); border-color: var(--color-success)">Shared</span>
271
+ <span class="badge" style="color: var(--color-success-ink); border-color: var(--color-success)">Shared</span>
272
272
  <p class="text-heading font-semibold text-sm">What every board inherits</p>
273
273
  </div>
274
274
  <ul class="text-secondary text-sm space-y-1 list-disc list-inside">
@@ -183,12 +183,13 @@
183
183
  function dsThemeEditor(seed) {
184
184
  return {
185
185
  colors: Object.assign({ primary: '#000', dark: '#0f172a', light: '#ffffff',
186
- success: '#4BAF50', warning: '#FF7C47', danger: '#EF4444',
186
+ success: '#367E3A', warning: '#FF7C47', danger: '#EF4444',
187
187
  accent: '#6366F1' }, seed || {}),
188
188
  saving: false,
189
189
  updatePreview() {
190
190
  var d = document.documentElement.style;
191
191
  d.setProperty('--color-cta', this.colors.primary);
192
+ d.setProperty('--color-cta-fill', this.colors.primary);
192
193
  if (this.colors.success) d.setProperty('--color-success', this.colors.success);
193
194
  if (this.colors.warning) d.setProperty('--color-warning', this.colors.warning);
194
195
  if (this.colors.danger) d.setProperty('--color-danger', this.colors.danger);
@@ -90,7 +90,7 @@
90
90
  locals: { klass: ".badge", usage: %(<span class="badge">Neutral</span>) } do %>
91
91
  <div class="flex flex-wrap items-center justify-center gap-2">
92
92
  <span class="badge text-body">Neutral</span>
93
- <span class="badge" style="color: var(--color-success); border-color: var(--color-success)">Success</span>
93
+ <span class="badge" style="color: var(--color-success-ink); border-color: var(--color-success)">Success</span>
94
94
  <span class="badge" style="color: var(--color-warning); border-color: var(--color-warning)">Warning</span>
95
95
  <span class="badge" style="color: var(--color-danger); border-color: var(--color-danger)">Danger</span>
96
96
  </div>
@@ -174,7 +174,7 @@
174
174
  <%= render layout: "style/specimen",
175
175
  locals: { klass: "in a status badge",
176
176
  usage: %(<span class="badge">Shipped ) + erb.call("at_time_tag(release.shipped_at)") + "</span>" } do %>
177
- <span class="badge" style="color: var(--color-success); border-color: var(--color-success)">Shipped <%= at_time_tag(42.minutes.ago) %></span>
177
+ <span class="badge" style="color: var(--color-success-ink); border-color: var(--color-success)">Shipped <%= at_time_tag(42.minutes.ago) %></span>
178
178
  <% end %>
179
179
  </div>
180
180
  </section>
@@ -442,7 +442,7 @@
442
442
  <div class="flex flex-wrap items-center gap-3">
443
443
  <h3 class="text-xl font-bold text-heading">Leveling</h3>
444
444
  <% if leveling_on %>
445
- <span class="badge" style="color: var(--color-success); border-color: var(--color-success)">enabled on this app</span>
445
+ <span class="badge" style="color: var(--color-success-ink); border-color: var(--color-success)">enabled on this app</span>
446
446
  <% else %>
447
447
  <span class="badge" style="color: var(--color-warning); border-color: var(--color-warning)">disabled on this app</span>
448
448
  <% end %>
@@ -71,13 +71,13 @@
71
71
  <p class="text-sm mb-3" :style="{ color: '#94a3b8' }">Card on surface with secondary text.</p>
72
72
  <div class="flex gap-2">
73
73
  <button class="btn btn-sm text-white rounded-lg px-3 py-1.5 text-xs font-bold" :style="{ backgroundColor: colors.primary }">Primary</button>
74
- <button class="btn btn-sm text-white rounded-lg px-3 py-1.5 text-xs font-bold" :style="{ backgroundColor: colors.success || '#4BAF50' }">Accent</button>
74
+ <button class="btn btn-sm text-white rounded-lg px-3 py-1.5 text-xs font-bold" :style="{ backgroundColor: colors.success || '#367E3A' }">Accent</button>
75
75
  <button class="btn btn-sm text-white rounded-lg px-3 py-1.5 text-xs font-bold" :style="{ backgroundColor: colors.danger || '#EF4444' }">Danger</button>
76
76
  </div>
77
77
  </div>
78
78
  <div class="flex gap-2">
79
79
  <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium" :style="{ backgroundColor: colors.primary + '33', color: colors.primary, border: '1px solid ' + colors.primary + '55' }">Badge</span>
80
- <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium" :style="{ backgroundColor: (colors.success || '#4BAF50') + '33', color: colors.success || '#4BAF50', border: '1px solid ' + (colors.success || '#4BAF50') + '55' }">Success</span>
80
+ <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium" :style="{ backgroundColor: (colors.success || '#367E3A') + '33', color: colors.success || '#367E3A', border: '1px solid ' + (colors.success || '#367E3A') + '55' }">Success</span>
81
81
  </div>
82
82
  </div>
83
83
  </div>
@@ -94,13 +94,13 @@
94
94
  <p class="text-sm mb-3" :style="{ color: '#64748b' }">Card on surface with secondary text.</p>
95
95
  <div class="flex gap-2">
96
96
  <button class="btn btn-sm text-white rounded-lg px-3 py-1.5 text-xs font-bold" :style="{ backgroundColor: colors.primary }">Primary</button>
97
- <button class="btn btn-sm text-white rounded-lg px-3 py-1.5 text-xs font-bold" :style="{ backgroundColor: colors.success || '#4BAF50' }">Accent</button>
97
+ <button class="btn btn-sm text-white rounded-lg px-3 py-1.5 text-xs font-bold" :style="{ backgroundColor: colors.success || '#367E3A' }">Accent</button>
98
98
  <button class="btn btn-sm text-white rounded-lg px-3 py-1.5 text-xs font-bold" :style="{ backgroundColor: colors.danger || '#EF4444' }">Danger</button>
99
99
  </div>
100
100
  </div>
101
101
  <div class="flex gap-2">
102
102
  <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium" :style="{ backgroundColor: colors.primary + '33', color: colors.primary, border: '1px solid ' + colors.primary + '55' }">Badge</span>
103
- <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium" :style="{ backgroundColor: (colors.success || '#4BAF50') + '33', color: colors.success || '#4BAF50', border: '1px solid ' + (colors.success || '#4BAF50') + '55' }">Success</span>
103
+ <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium" :style="{ backgroundColor: (colors.success || '#367E3A') + '33', color: colors.success || '#367E3A', border: '1px solid ' + (colors.success || '#367E3A') + '55' }">Success</span>
104
104
  </div>
105
105
  </div>
106
106
  </div>
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Resolves the two words the engine navbar says about who is (or is not) signed
4
+ # in: the signed-in user's name (Studio.navbar_user_name) and the signed-out
5
+ # button's label (Studio.sign_in_label). Pure Ruby, so the unit suite covers it
6
+ # without the dummy app.
7
+ #
8
+ # Studio.navbar_user_name is one of:
9
+ #
10
+ # nil the default: user.display_name, as before
11
+ # :player_name a method called on the user ("player_name" works too)
12
+ # ->(user, view) { ... } a callable; a one-argument callable gets the user only
13
+ #
14
+ # A layout must not 500 over a name. When the configured method or callable
15
+ # RAISES, the navbar prints display_name and the failure is reported once per
16
+ # process per error class (ErrorLog when the host has it, else Rails.logger),
17
+ # not once per page view. A BLANK answer falls back to display_name quietly:
18
+ # a user with no player name yet is data, not a fault.
19
+ #
20
+ # The name comes back as a plain String, never an html_safe buffer, so ERB
21
+ # escapes whatever a host's method or callable returned.
22
+ module Studio
23
+ module NavbarIdentity
24
+ class InvalidConfig < ArgumentError; end
25
+
26
+ DEFAULT_SIGN_IN_LABEL = "Log in"
27
+
28
+ @reported = {}
29
+ @reported_lock = Mutex.new
30
+
31
+ module_function
32
+
33
+ def user_name(user, view = nil, config: Studio.navbar_user_name)
34
+ fallback = user.display_name # outside the rescue: the default path is not ours to swallow
35
+ return fallback if config.nil?
36
+
37
+ begin
38
+ text = plain_text(configured_name(config, user, view))
39
+ rescue StandardError => e
40
+ report_once(e)
41
+ return fallback
42
+ end
43
+ text.empty? ? fallback : text
44
+ end
45
+
46
+ def validate_name!(config)
47
+ return nil if config.nil? || config.respond_to?(:call)
48
+ return nil if (config.is_a?(Symbol) || config.is_a?(String)) && !config.to_s.strip.empty?
49
+
50
+ raise InvalidConfig, "Studio.navbar_user_name must be nil, a method name (Symbol) or a callable " \
51
+ "->(user, view) (got #{config.inspect})"
52
+ end
53
+
54
+ def validate_label!(label)
55
+ return nil if label.is_a?(String) && !label.strip.empty?
56
+
57
+ raise InvalidConfig, "Studio.sign_in_label must be non-blank text (got #{label.inspect})"
58
+ end
59
+
60
+ # Forget what was reported, so a reconfigured name reports its own failure.
61
+ def reset_reported!
62
+ @reported_lock.synchronize { @reported.clear }
63
+ end
64
+
65
+ def configured_name(config, user, view)
66
+ return user.public_send(config.to_sym) unless config.respond_to?(:call)
67
+
68
+ config.respond_to?(:arity) && config.arity == 1 ? config.call(user) : config.call(user, view)
69
+ end
70
+
71
+ # String.new drops an html_safe flag: SafeBuffer#to_s returns the buffer
72
+ # itself, which ERB would print raw.
73
+ def plain_text(value)
74
+ String.new(value.to_s).strip
75
+ end
76
+
77
+ def report_once(error)
78
+ first = @reported_lock.synchronize { @reported[error.class.name] ? false : (@reported[error.class.name] = true) }
79
+ return unless first
80
+
81
+ if defined?(::ErrorLog) && ::ErrorLog.respond_to?(:capture!)
82
+ ::ErrorLog.capture!(error)
83
+ elsif defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
84
+ ::Rails.logger.warn("[studio-navbar] Studio.navbar_user_name failed, showing display_name: " \
85
+ "#{error.class}: #{error.message}")
86
+ end
87
+ rescue StandardError
88
+ nil # reporting is best-effort: the navbar still renders
89
+ end
90
+ end
91
+ end
@@ -2,6 +2,18 @@ module Studio
2
2
  class ThemeResolver
3
3
  ROLES = %i[primary dark light success warning danger accent].freeze
4
4
 
5
+ # The engine's default role colours — the resolver's fallbacks, and the
6
+ # same values lib/studio.rb ships as its theme_* defaults.
7
+ DEFAULT_PRIMARY = "#8E82FE"
8
+ # #367E3A, not the #4BAF50 it was until engine-navbar-phone-polish: the old
9
+ # green measured 2.78:1 under the white label btn-success draws on it. This
10
+ # is the same hue at 5.0:1 (the shade cyvasse chose for its own success).
11
+ DEFAULT_SUCCESS = "#367E3A"
12
+
13
+ # WCAG AA for normal-size text. Every filled button draws a 14px bold white
14
+ # label, which is not "large" text, so its fill owes 4.5:1 against white.
15
+ WHITE_LABEL_TARGET = 4.5
16
+
5
17
  attr_reader :colors
6
18
 
7
19
  # colors: hash of role => hex string (e.g. { primary: "#8E82FE", dark: "#1A1535", ... })
@@ -29,9 +41,9 @@ module Studio
29
41
 
30
42
  def dark_mode_vars
31
43
  dark_base = colors[:dark] || "#1A1535"
32
- primary = colors[:primary] || "#8E82FE"
44
+ primary = colors[:primary] || DEFAULT_PRIMARY
33
45
  border_rgb = ColorScale.lighten(dark_base, 0.30)
34
- success = colors[:success] || "#4BAF50"
46
+ success = colors[:success] || DEFAULT_SUCCESS
35
47
  warning = colors[:warning] || "#FF7C47"
36
48
  danger = colors[:danger] || "#EF4444"
37
49
  surfaces = dark_surfaces(dark_base)
@@ -69,8 +81,11 @@ module Studio
69
81
  "--color-border" => ColorScale.with_opacity(border_rgb, 0.2),
70
82
  "--color-border-strong" => ColorScale.with_opacity(border_rgb, 0.4),
71
83
  "--color-shadow" => "transparent",
84
+ # --color-cta stays the brand colour: apps also paint it as TEXT on
85
+ # dark surfaces, where a darker shade would lose contrast. The button
86
+ # fill under a white label is a separate, derived var: see #fill_vars.
72
87
  "--color-cta" => primary,
73
- "--color-cta-hover" => ColorScale.darken(primary, 0.30),
88
+ "--color-cta-hover" => cta_hover(primary),
74
89
  "--color-success" => success,
75
90
  "--color-warning" => warning,
76
91
  "--color-danger" => danger,
@@ -99,7 +114,7 @@ module Studio
99
114
  "--color-warning-ink" => status_ink(warning, surfaces, direction: :lighten),
100
115
  "--color-success-ink" => status_ink(success, surfaces, direction: :lighten),
101
116
  "--color-accent" => colors[:accent] || "#F72585"
102
- }
117
+ }.merge(fill_vars(primary, success))
103
118
  end
104
119
 
105
120
  # Every dark-mode background a text var can sit on (page, surface,
@@ -166,6 +181,38 @@ module Studio
166
181
  contrast_ink(role, direction: direction, start: 0.0, target: 4.5, against: surfaces + tints)
167
182
  end
168
183
 
184
+ # The lightest shade of `color` that carries a white label at AA: the
185
+ # colour itself when it already passes, otherwise the same hue darkened in
186
+ # 0.02 steps until it does. A fill shade, derived the way the text inks are.
187
+ def white_label_fill(color)
188
+ contrast_ink(color, direction: :darken, start: 0.0, target: WHITE_LABEL_TARGET, against: ["#ffffff"])
189
+ end
190
+
191
+ # --color-cta-fill: the fill btn-primary, btn-outline's hover and the user
192
+ # nav's level bar paint under a white label (the same in both modes).
193
+ # Emitted ONLY while the app leaves its primary at the engine default
194
+ # (#8E82FE, 3.1:1 under white), which is the engine's to make accessible.
195
+ # A CONFIGURED primary emits no fill, so those rules fall back to
196
+ # var(--color-cta), the app's own choice: mcritchie-industries pairs a navy
197
+ # label with its orange (a darkened fill would drop it to 3.3:1), and
198
+ # cyvasse sets --color-cta itself.
199
+ #
200
+ # Success needs no fill var: its DEFAULT is already AA under white
201
+ # (DEFAULT_SUCCESS), and a configured success colour is the app's own —
202
+ # turf-monster's suite pins that a configured #4BAF50 still paints as-is.
203
+ def fill_vars(primary, _success)
204
+ return {} unless primary.to_s.casecmp?(DEFAULT_PRIMARY)
205
+
206
+ { "--color-cta-fill" => white_label_fill(primary) }
207
+ end
208
+
209
+ # The hover fill is always derived, so it is always held to AA under white:
210
+ # darken(primary, 0.30) as before when that passes (every consumer palette
211
+ # today), darker only for a primary so light that 0.30 is not enough.
212
+ def cta_hover(primary)
213
+ contrast_ink(primary, direction: :darken, start: 0.30, target: WHITE_LABEL_TARGET, against: ["#ffffff"])
214
+ end
215
+
169
216
  # Bounded, clamped search: raise the blend amount from `start` until the
170
217
  # ink clears `target` contrast against every background in `against`.
171
218
  # Clamps at 1.0 (pure white/black), so a pathological base degrades to the
@@ -182,7 +229,7 @@ module Studio
182
229
 
183
230
  # Generate --color-primary-{50..900} + RGB variants for Tailwind opacity support
184
231
  def primary_palette_vars
185
- primary = colors[:primary] || "#8E82FE"
232
+ primary = colors[:primary] || DEFAULT_PRIMARY
186
233
  scale = ColorScale.generate(primary)
187
234
  vars = {}
188
235
 
@@ -202,8 +249,8 @@ module Studio
202
249
 
203
250
  def light_mode_vars
204
251
  light_base = colors[:light] || "#f8fafc"
205
- primary = colors[:primary] || "#8E82FE"
206
- success = colors[:success] || "#4BAF50"
252
+ primary = colors[:primary] || DEFAULT_PRIMARY
253
+ success = colors[:success] || DEFAULT_SUCCESS
207
254
  warning = colors[:warning] || "#FF7C47"
208
255
  danger = colors[:danger] || "#EF4444"
209
256
  surfaces = light_surfaces(light_base)
@@ -231,7 +278,7 @@ module Studio
231
278
  "--color-border-strong" => ColorScale.darken(light_base, 0.15),
232
279
  "--color-shadow" => "rgba(0,0,0,0.05)",
233
280
  "--color-cta" => primary,
234
- "--color-cta-hover" => ColorScale.darken(primary, 0.30),
281
+ "--color-cta-hover" => cta_hover(primary),
235
282
  "--color-success" => success,
236
283
  "--color-warning" => warning,
237
284
  "--color-danger" => danger,
@@ -256,7 +303,7 @@ module Studio
256
303
  "--color-warning-ink" => status_ink(warning, surfaces, direction: :darken),
257
304
  "--color-success-ink" => status_ink(success, surfaces, direction: :darken),
258
305
  "--color-accent" => colors[:accent] || "#F72585"
259
- }
306
+ }.merge(fill_vars(primary, success))
260
307
  end
261
308
  end
262
309
  end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.78.0"
2
+ VERSION = "0.80.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -13,6 +13,7 @@ require "studio/js_identifier"
13
13
  require "studio/partial_block"
14
14
  require "studio/sidebar_sections"
15
15
  require "studio/navbar_links"
16
+ require "studio/navbar_identity"
16
17
  require "studio/profile_sections"
17
18
  require "studio/profile_image"
18
19
  require "studio/oauth_identity"
@@ -192,6 +193,33 @@ module Studio
192
193
  @@navbar_links = links
193
194
  end
194
195
 
196
+ # ---- Navbar identity ----
197
+ # The name the signed-in user nav prints, and the signed-out button's label.
198
+ # Both defaults render the navbar byte-identical to before. The engine has no
199
+ # I18n catalogue, so the label is plain config rather than a locale key.
200
+ # Rules: lib/studio/navbar_identity.rb.
201
+ #
202
+ # config.navbar_user_name = :player_name # a method on the user
203
+ # config.navbar_user_name = ->(user, view) { user.player_name } # or a callable
204
+ # config.sign_in_label = "Sign in"
205
+ #
206
+ # nil means user.display_name. A method or callable that raises, or answers
207
+ # blank, falls back to display_name; a raise is reported once, never a 500.
208
+ mattr_reader :navbar_user_name, default: nil
209
+
210
+ def self.navbar_user_name=(config)
211
+ NavbarIdentity.validate_name!(config)
212
+ NavbarIdentity.reset_reported!
213
+ @@navbar_user_name = config
214
+ end
215
+
216
+ mattr_reader :sign_in_label, default: NavbarIdentity::DEFAULT_SIGN_IN_LABEL
217
+
218
+ def self.sign_in_label=(label)
219
+ NavbarIdentity.validate_label!(label)
220
+ @@sign_in_label = label
221
+ end
222
+
195
223
  # ---- The shared profile page (/profile) ----
196
224
  # The rows that make up /profile. `nil` — the default — means "the engine's
197
225
  # standard page", NOT "no rows": a brand-new app with an empty initializer gets
@@ -607,7 +635,9 @@ module Studio
607
635
  mattr_accessor :theme_primary, default: "#8E82FE"
608
636
  mattr_accessor :theme_dark, default: "#1A1535"
609
637
  mattr_accessor :theme_light, default: "#f8fafc"
610
- mattr_accessor :theme_success, default: "#4BAF50"
638
+ # #367E3A: the default green's AA shade under a white label (5.0:1). The old
639
+ # #4BAF50 measured 2.78:1 on btn-success. Studio::ThemeResolver::DEFAULT_SUCCESS.
640
+ mattr_accessor :theme_success, default: "#367E3A"
611
641
  mattr_accessor :theme_warning, default: "#FF7C47"
612
642
  mattr_accessor :theme_danger, default: "#EF4444"
613
643
  mattr_accessor :theme_accent, default: "#F72585"
@@ -900,6 +930,11 @@ module Studio
900
930
  NavbarLinks.resolve(navbar_links, view)
901
931
  end
902
932
 
933
+ # The signed-in user's navbar name for a view (lib/studio/navbar_identity.rb).
934
+ def self.navbar_user_name_for(user, view = nil)
935
+ NavbarIdentity.user_name(user, view, config: navbar_user_name)
936
+ end
937
+
903
938
  # The engine's standard /profile rows. Hosts compose against this rather than
904
939
  # restating a literal list, so a later release that adds a standard row
905
940
  # delivers it to every app that used the seam as intended.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.78.0
4
+ version: 0.80.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-29 00:00:00.000000000 Z
11
+ date: 2026-09-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -573,6 +573,7 @@ files:
573
573
  - lib/studio/log_rotation.rb
574
574
  - lib/studio/mail_transport.rb
575
575
  - lib/studio/name_parts.rb
576
+ - lib/studio/navbar_identity.rb
576
577
  - lib/studio/navbar_links.rb
577
578
  - lib/studio/newsletter.rb
578
579
  - lib/studio/oauth_identity.rb