studio-engine 0.72.0 → 0.72.2

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: a53d7cde61dcf497f1dee1542d64ad82714900227b0fb0c98c31df714ab67267
4
- data.tar.gz: 16d9816dde34b609d14b57f0c526f60883a79641f485775362e396cf4ac040b1
3
+ metadata.gz: dffda6dc9fdd89e1770f0d456e28cfb9a674679d09bc1226816f3f30ed8cf255
4
+ data.tar.gz: ef6f36d167f21886920a64cbc687ca0981c49bfb93117050cb74cabf60e8e875
5
5
  SHA512:
6
- metadata.gz: 682057c50bc19db2ab91d46d54ddb730d273617c1814b61e7bedea16120187af757ea44fe554cdbf66bc8b26f8356cb40050532b3cca146f7af6c73607632b21
7
- data.tar.gz: 59c7b9a8331368526b1561482cd86d117866a4e61ce2e07a499fb48a8c2eb4ecf6a588388347a3bb4c2d6973c9d599bc819c2eb4b6348212f38aaee753c20774
6
+ metadata.gz: 56ce7f21bc05749d76d3a6325f040892ad288f4c82a1bef8e62d64cf0666cd3d88a019a9b9591a375a8d5519ce536d9d6e629ab8dda9f5faaf98b12da6019e8c
7
+ data.tar.gz: ef82414cc1ed55acf4d35ccafea238e4c2defe39a2ce684ad1fb5e18d8f47dc928b1f07ba015be1fee6b847f357225d2e073b638f03da335270638275bc87f4a
data/CHANGELOG.md CHANGED
@@ -217,6 +217,45 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
217
217
 
218
218
  ### Fixed
219
219
 
220
+ - **The style guide's two "Sign Wallet" thumbnails no longer crown themselves
221
+ with a padlock the card stopped drawing, and the guide's lock no longer trails
222
+ the release that removed it.** solana-studio 0.6.1 replaced the step-up card's
223
+ padlock with the remembered wallet's own brand mark, falling back to a neutral
224
+ billfold where no brand is remembered. Both hand-drawn thumbnails in
225
+ `style/_modals` went on drawing the padlock, so the first thing a designer
226
+ reads advertised a glyph the design system had retired.
227
+
228
+ **THE THUMBNAILS WERE HALF OF IT.** The opened specimen renders the REAL
229
+ shared partial, but it does so through whatever `Gemfile.lock` resolves — so
230
+ it is only ever as current as the lock, and the lock sat on 0.6.0 while
231
+ turf-monster shipped 0.6.1. The guide's opened card was still drawing the
232
+ padlock too, which `bin/gem-drift-check` had already been failing on
233
+ (`engine 0.6.0 TRAILS turf-monster 0.6.1`). Correcting the sketches alone
234
+ would have put a brand mark on the thumbnail and a padlock in the card it
235
+ opens — moving the contradiction onto one screen rather than removing it. So
236
+ the lock had to move too, and it has: `accepted` carried the engine to
237
+ solana-studio 0.7.0 while this change sat in review, which is past the 0.6.1
238
+ that removed the padlock. This change no longer moves the lock itself — it
239
+ defers to the line `accepted` already holds — so the sketch and the card it
240
+ opens agree without it.
241
+
242
+ The sketches follow the card: a centered brand tile where a wallet is
243
+ remembered, the card's own billfold outline in an inset square where none is.
244
+ They stay SKETCHES rather than real brand marks for a mechanical reason worth
245
+ recording — every `se-wallet` symbol on the guide is defined inside a
246
+ `template x-if`, whose content is inert until Alpine clones it, so a `use`
247
+ placed outside one resolves to nothing and paints an empty box.
248
+
249
+ Guarded by `test/views/step_up_specimen_thumbnails_test.rb`, which slices each
250
+ thumbnail by a `data-test` hook before asserting. That slicing is the point:
251
+ the rendered guide already carried 17 `se-wallet-` hits from the picker and
252
+ the card's own CTA, so a page-wide assertion for the mark could not fail, and
253
+ a page-wide assertion against the padlock could not pass — the drag-board
254
+ specimen's prose legitimately contains one. The padlock is pinned in BOTH the
255
+ codepoint and the HTML-entity form. MEASURED: five mutants, five killed —
256
+ the padlock restored in either form, either header mark removed, and the lock
257
+ reverted to 0.6.0 each reddens the assertion that owns it.
258
+
220
259
  - **The style guide's page-scoped modal store now resolves animations through
221
260
  the LIVE registry, so the guide can no longer disagree with itself.**
222
261
  `style/_modals`' `dsModals` carried a hard-coded COPY of the animation table
data/Gemfile CHANGED
@@ -75,11 +75,29 @@ end
75
75
  # nothing — which is precisely why it went four releases unnoticed.)
76
76
  #
77
77
  # SO THE FIX IS A GATE, NOT A PIN. bin/gem-drift-check fails when this engine's
78
- # lock TRAILS a consumer's, and consumer-ci.yml runs it — the only lane holding
79
- # two repos' lockfiles at once. Direction is one-way: engine behind fails, engine
80
- # ahead or level passes, a consumer bundling no solana-studio is a skip. Keeping
81
- # up is now enforced rather than remembered, which is what lets the floor stay a
82
- # floor. Guarded by test/lib/gem_drift_check_test.rb.
78
+ # lock resolves an OLDER solana-studio than a consumer's, and consumer-ci.yml
79
+ # runs it — the only lane holding two repos' lockfiles at once. Direction is
80
+ # one-way: engine behind fails, engine ahead or level passes, a consumer
81
+ # bundling no solana-studio is a skip. Guarded by
82
+ # test/lib/gem_drift_check_test.rb.
83
+ #
84
+ # READ THE CONSUMER AS A MESSENGER, not as the target. The invariant is that
85
+ # this engine resolves the LATEST RELEASED solana-studio. A consumer's lock is
86
+ # compared only because it is the one newer-release fact on disk — the gate is
87
+ # stdlib-only and makes no network call, deliberately, so it cannot ask the
88
+ # registry directly. A consumer being ahead is EVIDENCE of a release this engine
89
+ # missed, never itself the thing to catch up to.
90
+ #
91
+ # AND THE GATE ALONE WAS NOT ENOUGH. Enforcement only reddens; a human still had
92
+ # to do the bump. Measured 2026-09-07: that human was late five times in ONE DAY
93
+ # — by 3h, 2h20m, 14h15m and 1h10m — and the worst case was not the longest but
94
+ # the fastest, a lock that went stale 36 minutes after the previous fix was
95
+ # committed. While it is stale EVERY open engine PR is red, over a line no PR
96
+ # author owns. So .github/dependabot.yml now opens the bump PR daily (scoped to
97
+ # solana-studio ALONE, so this repo never inherits the third-party graveyard),
98
+ # and .github/workflows/engine-lock-automerge.yml merges it when — and only
99
+ # when — all four of bin/lock-bump-mergeable's conditions hold. A breaking bump
100
+ # still stops, stays open and red, and still waits for a human.
83
101
  group :development, :test do
84
102
  gem "solana-studio", ">= 0.5.3"
85
103
  end
data/README.md CHANGED
@@ -305,6 +305,18 @@ the one credential that skips attestation) and `props.submitting`. It receives
305
305
  one local, `modal_store`: the engine's real host is `"modals"`, the living style
306
306
  guide's page-scoped host is `"dsModals"`.
307
307
 
308
+ **Name a store like a JavaScript identifier.** Partials splice this local in as a
309
+ bare name — `$store.<name>.current()` — rather than as a string, so a value
310
+ carrying a quote, a dot or a space is not a mangled store name: it is a
311
+ SyntaxError in the whole `x-data`, and Alpine mounts a component that renders
312
+ every element and does nothing. Escaping is not the repair (an escaped identifier
313
+ is a different SyntaxError); a name that matches `/\A[A-Za-z_$][A-Za-z0-9_$]*\z/`
314
+ is. `studio/modals/onboarding/_first_name` enforces exactly that and raises
315
+ `ArgumentError` on anything else — the first partial to do so, not yet the fleet.
316
+ Note the shape decides the repair, not the name of the local: `blocks/_birthday`
317
+ and `blocks/_leveling_activity` pass `modal_store` in *string* position and
318
+ correctly `escape_javascript` it instead.
319
+
308
320
  It also keeps two blocks the gem renders **by name** across the gem boundary:
309
321
  `studio/modals/blocks/wallet_brand_sprite` and `studio/modals/blocks/card_header`.
310
322
  Renaming either is a cross-repo change.
@@ -45,6 +45,17 @@ module Studio
45
45
  def first_name
46
46
  value = params[:first_name].to_s.strip.gsub(/\s+/, " ")
47
47
 
48
+ # THE SAME SENTENCE AS THE CARD'S EMPTY-FIELD ERROR, and deliberately NOT
49
+ # parameterised the way that one now is. `required` is a RENDER-TIME local of
50
+ # studio/modals/onboarding/_first_name; the server is never told which mode
51
+ # the card was drawn in, so this branch has no `required` to follow, and
52
+ # rewording it unconditionally would move McRitchie Studio's copy to say
53
+ # something only turf's gate needs.
54
+ #
55
+ # It is also UNREACHABLE FROM THE CARD: save() returns on a blank value
56
+ # before it posts, so what arrives here is a direct POST or a host driving
57
+ # these endpoints with its own form. If that ever becomes a mode-sensitive
58
+ # surface, the caller says which mode it is in — this string does not guess.
48
59
  if value.blank?
49
60
  return refuse("Enter your first name, or skip for now.")
50
61
  end
@@ -10,8 +10,11 @@
10
10
  This partial owns ONE step; the host owns the sequence.
11
11
 
12
12
  Locals (all optional, defaults via local_assigns.fetch):
13
- submit_path — POST target for the name (default "/onboarding/first_name")
14
- skip_path — POST target for the skip (default "/onboarding/skip_first_name")
13
+ submit_path — POST target for the name (default "/onboarding/first_name").
14
+ Interpolated into the x-data, so it is JS-escaped on the way in
15
+ — see THE TWO SHAPES below.
16
+ skip_path — POST target for the skip (default "/onboarding/skip_first_name").
17
+ JS-escaped, same as submit_path.
15
18
  required — hide the SKIP affordances (default false, which keeps today's
16
19
  skippable card). The × then merely CLOSES and is labelled
17
20
  Close, and the "Skip for now" button is not rendered. For a
@@ -24,6 +27,11 @@
24
27
  follows `required`, because a card that cannot be skipped is
25
28
  talking to a different audience — a host that passes its own
26
29
  subtext still wins)
30
+ empty_error — the inline error shown when the field is submitted EMPTY. Like
31
+ subtext, its default follows `required`: a card that renders no
32
+ skip must not answer an empty field by offering one. Rendered
33
+ INSIDE the x-data, so it is escaped for a JS single-quoted
34
+ string on the way in — see CRITICAL below.
27
35
  placeholder — (default "Alex")
28
36
  placeholder_names — OPTIONAL, and OFF unless passed: an array of first names,
29
37
  one of which is TYPED into the placeholder a character at a
@@ -43,9 +51,13 @@
43
51
  than store a name nobody typed)
44
52
  progress — [current, total] to render the segmented pill, or nil for none
45
53
  modal_store — Alpine store name (default "modals"; the living style guide
46
- mounts its own page-scoped host and passes "dsModals")
54
+ mounts its own page-scoped host and passes "dsModals"). It lands
55
+ in IDENTIFIER position, not string position, so it is VALIDATED
56
+ rather than escaped and a value that is not a JS identifier
57
+ raises ArgumentError at render — see THE TWO SHAPES below.
47
58
  done_event — window event dispatched when this step is finished, saved or
48
- skipped (default "onboarding-step-done"). Its detail carries
59
+ skipped (default "onboarding-step-done"). JS-escaped, same as
60
+ submit_path. Its detail carries
49
61
  `{ next: [...], saved: true|false }` — see THE OUTCOME below.
50
62
  The HOST decides what happens next; this partial never knows.
51
63
 
@@ -73,6 +85,38 @@
73
85
  DOUBLE-QUOTED attribute — a single " anywhere inside it (a code comment
74
86
  included) closes it early and the whole component mounts as a silent no-op that
75
87
  still renders markup. Keep every inner string SINGLE-quoted.
88
+
89
+ THE TWO SHAPES — read this before adding a local to the x-data. Five locals are
90
+ interpolated into that attribute and they do NOT take the same repair. Getting
91
+ this wrong does not raise; it mounts a card that renders every element and does
92
+ nothing, which is why it is written down here rather than left to be noticed.
93
+
94
+ STRING position — empty_error, submit_path, skip_path, done_event — each sits
95
+ inside a JS SINGLE-quoted literal. A bare apostrophe closes the literal, the
96
+ expression becomes a SyntaxError, and the card is that silent no-op. Repair:
97
+ escape_javascript, in the INTERPOLATED form. The wrapper is load-bearing:
98
+ escape_javascript(SafeBuffer) answers true to html_safe, so ERB would skip
99
+ its own attribute-escaping half and a raw double quote could still close the
100
+ attribute. Wrapping the value in a plain interpolation first strips the safe
101
+ marking, so both escapers run.
102
+
103
+ IDENTIFIER position — modal_store — is spliced in as a bare NAME, at three
104
+ sites: the props getter, finish's close, and the Ruby-built dismiss_action
105
+ emitted into the x's click handler. Escaping is the WRONG repair here: an
106
+ escaped identifier is a different SyntaxError and the same dead card.
107
+ Repair: refuse a value that is not a JS identifier, which the Ruby block
108
+ below does once, at the source, so all three sites are covered.
109
+
110
+ A PATTERN, NOT AN ALLOWLIST, and the reason is worth keeping: an allowlist would
111
+ be this engine enumerating its own consumers, so the next app to mount a
112
+ page-scoped host would be refused by its own dependency until a gem release
113
+ admitted the name. The contract is narrower and stateless — it is spliced into
114
+ member-access position, so it must be an identifier, and which one is the host's
115
+ business.
116
+
117
+ NOTE THE SAME LOCAL TAKES THE OTHER REPAIR ELSEWHERE. blocks/_birthday and
118
+ blocks/_leveling_activity pass modal_store in STRING position and escape it,
119
+ correctly. The shape decides the repair, never the name of the local.
76
120
  %>
77
121
  <%
78
122
  submit_path = local_assigns.fetch(:submit_path, "/onboarding/first_name")
@@ -85,6 +129,31 @@
85
129
  "Just your first name — we use it to address you in emails."
86
130
  end
87
131
  subtext = local_assigns.fetch(:subtext, default_subtext)
132
+ # The inline error for an EMPTY field, resolved here for the same reason
133
+ # default_subtext is: `required` is known at render time, and a card that
134
+ # renders no skip button must not answer an empty field by offering one. It was
135
+ # a hard-coded literal until now, which is why a gated card told the user to
136
+ # skip for now and pointed at a button that is not on the page.
137
+ default_empty_error = if required
138
+ "Enter your first name to continue."
139
+ else
140
+ "Enter your first name, or skip for now."
141
+ end
142
+ empty_error = local_assigns.fetch(:empty_error, default_empty_error)
143
+ # ESCAPED FOR THE x-data, which is the hazard this local introduces: it is the
144
+ # first HOST-SUPPLIED PROSE to go inside that attribute, and prose has
145
+ # apostrophes. Interpolated raw, an error reading "We'll need a name" would
146
+ # close the JS single-quoted string, make the whole expression a SyntaxError,
147
+ # and mount the component as a SILENT NO-OP that still renders every element
148
+ # below. escape_javascript covers the apostrophe, the double quote, the
149
+ # backslash and the newline; the interpolation around it first strips any
150
+ # html_safe marking, because a safe string would skip ERB's own attribute
151
+ # escaping and could smuggle a raw double quote in — the exact failure the
152
+ # CRITICAL note above describes.
153
+ #
154
+ # INERT ON BOTH DEFAULTS. Neither default string contains a character either
155
+ # escaper touches, so the shipped card is byte-for-byte what it was.
156
+ empty_error_js = escape_javascript("#{empty_error}")
88
157
  placeholder = local_assigns.fetch(:placeholder, "Alex")
89
158
  # OFF unless a host passes a non-empty array. Normalised to nil so that an
90
159
  # empty list behaves exactly like an absent local rather than emitting the
@@ -95,7 +164,58 @@
95
164
  max_length = local_assigns.fetch(:max_length, Studio::FULL_NAME_MAX_LENGTH)
96
165
  progress = local_assigns.fetch(:progress, nil)
97
166
  modal_store = local_assigns.fetch(:modal_store, "modals")
167
+ # VALIDATED, NOT ESCAPED — and that distinction is the whole point of this local.
168
+ # Every other value inside the x-data is a JS STRING and is repaired by escaping.
169
+ # This one is spliced in as a bare NAME (`$store.<name>.current()`), so the same
170
+ # repair breaks it: escape_javascript turns `a'b` into `a\'b`, and
171
+ # `$store.a\'b.current()` is not a rescued identifier, it is a different
172
+ # SyntaxError — the same silently dead card, reached a longer way.
173
+ #
174
+ # A PATTERN RATHER THAN AN ALLOWLIST, deliberately. An allowlist would be this
175
+ # engine enumerating its own CONSUMERS — "modals" and the style guide's
176
+ # "dsModals" today — so the next app to mount a page-scoped host would be refused
177
+ # by its own dependency until a gem release admitted the name. That is backwards
178
+ # for a shared primitive. The real contract is narrower and stateless: this is
179
+ # spliced into member-access position, so it must be a JS identifier, and WHICH
180
+ # identifier is none of the engine's business.
181
+ #
182
+ # ASCII IdentifierName, which is what member access after a dot accepts. It
183
+ # admits every store name a host would plausibly write and rejects every
184
+ # character that could leave identifier position — quote, apostrophe, backslash,
185
+ # dot, space, semicolon, angle bracket.
186
+ #
187
+ # IT RAISES RATHER THAN FALLING BACK TO THE DEFAULT. A silent fallback is the
188
+ # worse of the two repairs: the card would mount, look perfect, and talk to a
189
+ # store that is not the host's — which is the SILENT-brick failure class this
190
+ # guard exists to leave behind. A host sees this the first time it renders.
191
+ unless modal_store.to_s.match?(/\A[A-Za-z_$][A-Za-z0-9_$]*\z/)
192
+ raise ArgumentError,
193
+ "modal_store must be a JS identifier (it is spliced into $store.<name>); " \
194
+ "got #{modal_store.inspect}. Escaping it would not help — an escaped " \
195
+ "identifier is a different SyntaxError, and the card would mount dead."
196
+ end
98
197
  done_event = local_assigns.fetch(:done_event, "onboarding-step-done")
198
+ # THE SAME HAZARD empty_error_js CARRIES, on the three remaining locals that land
199
+ # inside a JS SINGLE-quoted literal in the x-data below. A bare apostrophe in any
200
+ # of them closes its literal, the whole expression becomes a SyntaxError, and
201
+ # Alpine mounts the component as a SILENT NO-OP that still renders every element
202
+ # — perfect-looking markup, dead card.
203
+ #
204
+ # THE INTERPOLATED FORM IS LOAD-BEARING, not a style choice:
205
+ # escape_javascript(SafeBuffer).html_safe? is TRUE, so ERB would skip its own
206
+ # attribute-escaping half and a raw double quote could still close the
207
+ # double-quoted x-data. Wrapping in "#{}" first strips the html_safe marking, so
208
+ # both escapers run: escape_javascript for the JS literal, ERB for the attribute.
209
+ #
210
+ # INERT ON EVERY DEFAULT. Two paths, an event name and a store name carry no
211
+ # character either escaper touches, so the shipped card is byte-for-byte what it
212
+ # was — the golden-fixture pin in the view test says so. This is latent cover: no
213
+ # host supplies these as prose today, but empty_error established that prose does
214
+ # belong in this attribute, and the next local to carry an apostrophe will look
215
+ # like an ordinary change to whoever writes it.
216
+ submit_path_js = escape_javascript("#{submit_path}")
217
+ skip_path_js = escape_javascript("#{skip_path}")
218
+ done_event_js = escape_javascript("#{done_event}")
99
219
  field_id = local_assigns.fetch(:id, "onboarding-first-name")
100
220
  # The × mirrors the skip affordance it sits beside: it SKIPS while the step is
101
221
  # skippable, and merely CLOSES once it is required. Resolved here rather than in
@@ -175,9 +295,9 @@
175
295
  async save() {
176
296
  if (this.submitting) return;
177
297
  var value = (this.firstName || '').trim();
178
- if (!value) { this.error = 'Enter your first name, or skip for now.'; return; }
298
+ if (!value) { this.error = '<%= empty_error_js %>'; return; }
179
299
  this.submitting = true; this.error = '';
180
- var data = await this.post('<%= submit_path %>', { first_name: value });
300
+ var data = await this.post('<%= submit_path_js %>', { first_name: value });
181
301
  this.submitting = false;
182
302
  if (!data || !data.ok) {
183
303
  this.error = (data && data.error) || 'Could not save that — try again.';
@@ -188,7 +308,7 @@
188
308
  async skip() {
189
309
  if (this.submitting) return;
190
310
  this.submitting = true; this.error = '';
191
- var data = await this.post('<%= skip_path %>', {});
311
+ var data = await this.post('<%= skip_path_js %>', {});
192
312
  this.submitting = false;
193
313
  this.finish((data && data.next) || [], false);
194
314
  },
@@ -211,7 +331,7 @@
211
331
  <% if typed_placeholder %>
212
332
  this.stopPlaceholder();
213
333
  <% end %>
214
- window.dispatchEvent(new CustomEvent('<%= done_event %>', { detail: { next: next, saved: !!saved } }));
334
+ window.dispatchEvent(new CustomEvent('<%= done_event_js %>', { detail: { next: next, saved: !!saved } }));
215
335
  $store.<%= modal_store %>.close();
216
336
  }
217
337
  }"
@@ -1291,9 +1291,22 @@
1291
1291
  on a 20px row, which is not a difference anyone can see at thumbnail
1292
1292
  size — and these two cards are the pair most likely to be mistaken for
1293
1293
  duplicates, because they share a modal id AND a title. So the sketch
1294
- now shows the actual distinction: this card KNOWS which wallet. %>
1295
- <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
1296
- <div class="text-2xl leading-none">🔐</div>
1294
+ now shows the actual distinction: this card KNOWS which wallet.
1295
+
1296
+ THE HEADER IS THE WALLET'S OWN MARK, not a padlock. This crowned a
1297
+ padlock until 2026-09-07, which the card had already stopped drawing —
1298
+ a thumbnail showing a glyph the card never paints is the style guide
1299
+ misreporting the design system. The card now heads itself with the
1300
+ remembered wallet's brand mark, so the sketch heads itself with a
1301
+ brand tile, the same idiom the Connect-wallet thumbnail above uses.
1302
+
1303
+ A REAL SPRITE CANNOT BE USED HERE, and it is worth saying why so the
1304
+ next reader does not try. Every se-wallet symbol on this page is
1305
+ defined inside a template x-if, whose content is inert until Alpine
1306
+ clones it, so a use outside one resolves to nothing and paints an
1307
+ empty box. Thumbnails are sketches for that reason, not only taste. %>
1308
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2" data-test="step-up-thumb-brand">
1309
+ <span class="block w-7 h-7 mx-auto rounded-lg" style="background: var(--color-primary)"></span>
1297
1310
  <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
1298
1311
  <span class="flex items-center gap-1.5 h-7 w-full rounded-lg border px-1.5" style="border-color: var(--color-border-strong)">
1299
1312
  <span class="w-4 h-4 rounded shrink-0" style="background: var(--color-primary)"></span>
@@ -1311,9 +1324,21 @@
1311
1324
  disabled: web3_card_off, openable: web3_gem } do %>
1312
1325
  <%# An EMPTY slot: dashed outline, no brand tile, a "?" where the wallet's
1313
1326
  name would be. Reads at a glance as "we do not know which wallet",
1314
- which is the whole difference between this card and the one before it. %>
1315
- <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
1316
- <div class="text-2xl leading-none">🔐</div>
1327
+ which is the whole difference between this card and the one before it.
1328
+
1329
+ THE HEADER IS THE NEUTRAL BILLFOLD the card falls back to when no
1330
+ brand is remembered — the same rounded, inset-filled square drawn with
1331
+ the card's own billfold outline. It replaced a padlock on 2026-09-07,
1332
+ for the reason given on the card above this one. Drawing the billfold
1333
+ rather than tinting a tile keeps the two headers as different as the
1334
+ two cards are: one names a wallet, this one cannot. %>
1335
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2" data-test="step-up-thumb-no-brand">
1336
+ <span class="inline-flex w-7 h-7 rounded-lg bg-inset items-center justify-center" aria-hidden="true">
1337
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" class="w-4 h-4 text-secondary">
1338
+ <rect x="3" y="6.5" width="18" height="11" rx="2.5" />
1339
+ <circle cx="16.6" cy="12" r="1.3" fill="currentColor" stroke="none" />
1340
+ </svg>
1341
+ </span>
1317
1342
  <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
1318
1343
  <span class="flex items-center justify-center h-7 w-full rounded-lg border border-dashed" style="border-color: var(--color-border-strong)">
1319
1344
  <span class="text-xs font-bold text-muted leading-none">?</span>
@@ -32,6 +32,13 @@ module Studio
32
32
  primary = colors[:primary] || "#8E82FE"
33
33
  border_rgb = ColorScale.lighten(dark_base, 0.30)
34
34
  danger = colors[:danger] || "#EF4444"
35
+ surfaces = dark_surfaces(dark_base)
36
+
37
+ secondary_ink = contrast_ink(dark_base, direction: :lighten, start: 0.70, target: 4.5, against: surfaces)
38
+ muted_ink = ladder_clamp(
39
+ contrast_ink(dark_base, direction: :lighten, start: 0.55, target: 4.5, against: surfaces),
40
+ secondary_ink, direction: :lighten
41
+ )
35
42
 
36
43
  {
37
44
  "--color-page" => dark_base,
@@ -48,10 +55,15 @@ module Studio
48
55
  # until the ink clears its target on every emitted dark surface
49
56
  # (clamped at pure white for pathological bases). Note the blend
50
57
  # DESATURATES toward gray; only strongly-tinted bases keep a cast.
51
- "--color-text-secondary" => contrast_ink(dark_base, direction: :lighten, start: 0.70, target: 4.5,
52
- against: dark_surfaces(dark_base)),
53
- "--color-text-muted" => contrast_ink(dark_base, direction: :lighten, start: 0.55, target: 3.0,
54
- against: dark_surfaces(dark_base)),
58
+ "--color-text-secondary" => secondary_ink,
59
+ # MUTED IS NORMAL-SIZE TEXT, so its target is AA 4.5:1 — not 3.0.
60
+ # 3.0 is WCAG's LARGE-text allowance (>=18.66px, or 14px bold) and this
61
+ # ink does not land on large text: the engine's own `.label-upper`
62
+ # utility is `text-xs text-muted` (12px), and consumers render it at
63
+ # 11px. Measured on the default theme before this change: muted was
64
+ # #9896A4 at 3.84:1 on --color-surface (dark) and #818283 at 3.46:1 on
65
+ # --color-surface-alt (light) — both below AA, in BOTH themes.
66
+ "--color-text-muted" => muted_ink,
55
67
  "--color-border" => ColorScale.with_opacity(border_rgb, 0.2),
56
68
  "--color-border-strong" => ColorScale.with_opacity(border_rgb, 0.4),
57
69
  "--color-shadow" => "transparent",
@@ -95,6 +107,31 @@ module Studio
95
107
  ColorScale.darken(light_base, 0.08) ]
96
108
  end
97
109
 
110
+ # Keep the ink ladder monotonic: muted is the QUIETEST text ink and must
111
+ # never come out louder than secondary.
112
+ #
113
+ # This became reachable the moment muted's target rose to 4.5 and the two
114
+ # inks started sharing one threshold. They are found by the same stepped
115
+ # search from DIFFERENT starts (muted 0.40/0.55, secondary 0.55/0.70), so
116
+ # their grids are offset and the one that starts lower can overshoot PAST
117
+ # the one that starts higher. Measured on the default light base #f8fafc:
118
+ # the true minimum blend clearing 4.5 is 0.59, secondary lands exactly
119
+ # there, and muted — stepping 0.40, 0.42, ... — skips 0.59 and lands on
120
+ # 0.60, i.e. DARKER than secondary. The ladder inverted while every
121
+ # contrast assertion stayed green, because nothing compared the two.
122
+ #
123
+ # Ordering is a design decision, so make it structurally rather than let a
124
+ # 0.02 grid decide it. `direction` says which way "louder" runs: lightened
125
+ # ink on a dark base is louder as luminance RISES; darkened ink on a light
126
+ # base is louder as luminance FALLS.
127
+ def ladder_clamp(muted, secondary, direction:)
128
+ muted_l = ColorScale.relative_luminance(muted)
129
+ secondary_l = ColorScale.relative_luminance(secondary)
130
+ louder = direction == :lighten ? muted_l > secondary_l : muted_l < secondary_l
131
+
132
+ louder ? secondary : muted
133
+ end
134
+
98
135
  # Bounded, clamped search: raise the blend amount from `start` until the
99
136
  # ink clears `target` contrast against every background in `against`.
100
137
  # Clamps at 1.0 (pure white/black), so a pathological base degrades to the
@@ -133,6 +170,13 @@ module Studio
133
170
  light_base = colors[:light] || "#f8fafc"
134
171
  primary = colors[:primary] || "#8E82FE"
135
172
  danger = colors[:danger] || "#EF4444"
173
+ surfaces = light_surfaces(light_base)
174
+
175
+ secondary_ink = contrast_ink(light_base, direction: :darken, start: 0.55, target: 4.5, against: surfaces)
176
+ muted_ink = ladder_clamp(
177
+ contrast_ink(light_base, direction: :darken, start: 0.40, target: 4.5, against: surfaces),
178
+ secondary_ink, direction: :darken
179
+ )
136
180
 
137
181
  {
138
182
  "--color-page" => light_base,
@@ -144,10 +188,9 @@ module Studio
144
188
  # Same bounded search as dark mode: the old fixed grays measured as
145
189
  # low as 2.05:1 (muted on --color-inset) — below the very defect this
146
190
  # derivation exists to prevent. Ink darkens away from the light base.
147
- "--color-text-secondary" => contrast_ink(light_base, direction: :darken, start: 0.55, target: 4.5,
148
- against: light_surfaces(light_base)),
149
- "--color-text-muted" => contrast_ink(light_base, direction: :darken, start: 0.40, target: 3.0,
150
- against: light_surfaces(light_base)),
191
+ "--color-text-secondary" => secondary_ink,
192
+ # See the dark-mode note: muted is normal-size text and owes AA 4.5:1.
193
+ "--color-text-muted" => muted_ink,
151
194
  "--color-border" => ColorScale.darken(light_base, 0.08),
152
195
  "--color-border-strong" => ColorScale.darken(light_base, 0.15),
153
196
  "--color-shadow" => "rgba(0,0,0,0.05)",
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.72.0"
2
+ VERSION = "0.72.2"
3
3
  end
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.72.0
4
+ version: 0.72.2
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-07 00:00:00.000000000 Z
11
+ date: 2026-09-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails