studio-engine 0.72.1 → 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 +4 -4
- data/CHANGELOG.md +39 -0
- data/Gemfile +23 -5
- data/README.md +12 -0
- data/app/controllers/studio/onboarding_controller.rb +11 -0
- data/app/views/studio/modals/onboarding/_first_name.html.erb +128 -8
- data/app/views/style/_modals.html.erb +31 -6
- data/lib/studio/theme_resolver.rb +51 -8
- data/lib/studio/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dffda6dc9fdd89e1770f0d456e28cfb9a674679d09bc1226816f3f30ed8cf255
|
|
4
|
+
data.tar.gz: ef6f36d167f21886920a64cbc687ca0981c49bfb93117050cb74cabf60e8e875
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
79
|
-
# two repos' lockfiles at once. Direction is
|
|
80
|
-
# ahead or level passes, a consumer
|
|
81
|
-
#
|
|
82
|
-
#
|
|
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
|
-
|
|
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").
|
|
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 = '
|
|
298
|
+
if (!value) { this.error = '<%= empty_error_js %>'; return; }
|
|
179
299
|
this.submitting = true; this.error = '';
|
|
180
|
-
var data = await this.post('<%=
|
|
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('<%=
|
|
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('<%=
|
|
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
|
-
|
|
1296
|
-
|
|
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
|
-
|
|
1316
|
-
|
|
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" =>
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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" =>
|
|
148
|
-
|
|
149
|
-
"--color-text-muted" =>
|
|
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)",
|
data/lib/studio/version.rb
CHANGED
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.
|
|
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-
|
|
11
|
+
date: 2026-09-08 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|