studio-engine 0.72.1 → 0.72.3
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 +94 -0
- data/Gemfile +23 -5
- data/README.md +12 -0
- data/app/controllers/studio/onboarding_controller.rb +11 -0
- data/app/views/layouts/studio/_head.html.erb +261 -152
- 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: 13abdd4ef0f10262a6560c40b2bed302c16d9f3b3b21582f3569cf0a7517cdf8
|
|
4
|
+
data.tar.gz: 6f47a52a0ba75d8be96ff6200b75d30a1ff49d76db401581a17a0ea88b2dcd6c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4b82e9d95ec946a5ce8fdc2d82269eadd8e300bfe21e146163aa0105a3a96254065ac10b26dd6f87a29cff5a402de1ca6d9cb0cdb53ef7119604ee0102b841d5
|
|
7
|
+
data.tar.gz: f03181c1586eb6c147b50778614df087115b27d1e76eb0dee7236f6e73c68a4509dadde3448d04cb84ea16cad4d20ffbe9eef8fcb7e96eca4be34dbd4c080d52
|
data/CHANGELOG.md
CHANGED
|
@@ -185,6 +185,61 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
185
185
|
|
|
186
186
|
### Changed
|
|
187
187
|
|
|
188
|
+
- **The pinned stack composes itself, and publishes in the frame the change
|
|
189
|
+
happened.** `data-pin` layers now also publish `--pin-stack-bottom` (the bottom
|
|
190
|
+
of the whole stack) and `--pin-<name>-top` (the bottom of everything above that
|
|
191
|
+
layer), and the publisher writes them from inside its ResizeObserver callback
|
|
192
|
+
rather than deferring to `requestAnimationFrame`.
|
|
193
|
+
|
|
194
|
+
**THE DEFECT.** A frame runs `rAF -> style/layout -> ResizeObserver -> paint`,
|
|
195
|
+
so a write made in the frame callback lands at the top of the NEXT frame. Every
|
|
196
|
+
frame in which a pinned layer appeared or disappeared therefore painted with the
|
|
197
|
+
previous frame's number. Measured in isolation by toggling a layer's `display`
|
|
198
|
+
and sampling what the changing frame paints: rAF-deferred wrong on 8/8 changing
|
|
199
|
+
frames, synchronous wrong on 0/8. On mcritchie-studio's production
|
|
200
|
+
`/deployments` it showed as the board's swim-lane headers slamming 99px and back
|
|
201
|
+
on a page nobody was touching — parked at a fixed scroll offset with document
|
|
202
|
+
height, row position and nav height all constant, the header's `top` went
|
|
203
|
+
152 -> 53 -> 152 -> 53px in 240ms behind two Turbo broadcasts.
|
|
204
|
+
|
|
205
|
+
**AND CONSUMERS NO LONGER COMPOSE THE STACK.** `top: max(var(--pin-nav-bottom),
|
|
206
|
+
var(--pin-apps-bottom))` fails twice: it does not scale, because a fourth layer
|
|
207
|
+
means editing every consumer that ever wanted to sit under the stack; and it is
|
|
208
|
+
not sound, because a `max()` over two custom properties is only meaningful if
|
|
209
|
+
they were written in the same frame. `--pin-stack-bottom` is one value that
|
|
210
|
+
cannot disagree with itself. A layer that is ITSELF in the stack reads
|
|
211
|
+
`--pin-<name>-top` instead, which excludes its own edge — positioning off the
|
|
212
|
+
stack bottom would make it chase itself down the page. Stacking order is
|
|
213
|
+
document order, with `data-pin-order` overriding it for a layer whose DOM
|
|
214
|
+
position does not match where it sits on screen.
|
|
215
|
+
|
|
216
|
+
**THE REGISTRY IS DERIVED PER PUBLISH, never cached.** A Turbo Stream replaces a
|
|
217
|
+
pinned layer's node without a `turbo:load`, and a held reference then points at
|
|
218
|
+
the DETACHED predecessor, whose rect is all zeros — indistinguishable from a
|
|
219
|
+
layer that is legitimately hidden. Re-querying makes the stale reference
|
|
220
|
+
impossible rather than merely unlikely.
|
|
221
|
+
|
|
222
|
+
**THE COST, re-measured**, because the rule this reverses was itself justified by
|
|
223
|
+
a measurement (turf-monster, 6x CPU throttle, frames over 20ms 13/24 through the
|
|
224
|
+
collapse ramp vs 0/24 once the observer was coalesced). What was expensive there
|
|
225
|
+
was read-write thrash, not the observer: the publisher now takes one read pass
|
|
226
|
+
and one write pass per invocation, and in an RO callback layout is already clean,
|
|
227
|
+
so those reads force nothing. On the engine lab at the same 6x throttle, ~700
|
|
228
|
+
frames of ramp each way — `RO through rAF` median 8.3ms / p90 10.0ms / 0 frames
|
|
229
|
+
over 20ms; `RO synchronous` median 8.4-8.5ms / p90 11.0ms / 3-6 frames over 20ms;
|
|
230
|
+
ZERO frames over 32ms on either, so neither drops one.
|
|
231
|
+
|
|
232
|
+
**BACK-COMPAT.** `--nav-h`, `--nav-bottom` and `--pin-<name>-h` /
|
|
233
|
+
`--pin-<name>-bottom` publish exactly as before, from the same measurements, so
|
|
234
|
+
the 26 consumers across turf-monster, rolio and mcritchie-studio need no change
|
|
235
|
+
and no floor bump. A structural change (a Turbo patch, a fresh document, a layer
|
|
236
|
+
joining or leaving) now forces a full write, because the unchanged-write skip is
|
|
237
|
+
only safe while nothing else touches these properties — the lab caught
|
|
238
|
+
`--nav-h` staying empty through a re-scan on exactly the host-owned-header path
|
|
239
|
+
both live consumers use. The lane contract moves 131 -> 132
|
|
240
|
+
(`config/e2e_lane.yml`), re-derived with the lister.
|
|
241
|
+
|
|
242
|
+
|
|
188
243
|
- **`Gemfile.lock` resolves solana-studio 0.5.7, and a gate now keeps it there.** The lock had sat on **0.5.3 for four patch releases** while BOTH consumers shipped 0.5.7 (turf-monster `~> 0.5.3`, mcritchie-studio `~> 0.5`). Nothing was red and nothing could have been: engine CI installs with `bundler-cache: true`, so it resolves from the lock and never fresh — the drift does not self-correct and never surfaces as flakiness. It matters because `test/views/style_web3_specimens_test.rb` exists to prove "the style guide renders the REAL gem cards" and reads them off whatever the LOCK resolved; four versions behind, that guard certifies a card no consumer receives. It still passes — only its MEANING changes. MEASURED on this span, the gem's whole `app/` tree was byte-identical 0.5.3 → 0.5.7 (only `CHANGELOG.md`, `README.md` and `version.rb` differ), so this instance cost nothing, which is exactly why it went four releases unnoticed.
|
|
189
244
|
- **The constraint stays `>= 0.5.3`, deliberately.** 0.5.3 is a real FLOOR (0.5.2 shipped the credential partial without `solana_studio/modals/_wallet_connect`, which the web3 capability gate requires). Above it this engine claims no ceiling: it is the BASE half of the base/bolt-on split, and a pessimistic `~> 0.5.3` here would be NARROWER than mcritchie-studio's own `~> 0.5` — a dev-only dependency constraining a resolution it does not own. The defect was the LOCK, not the pin, so the fix is a gate rather than a tighter pin.
|
|
190
245
|
- **`bin/gem-drift-check`** — fails when this engine's lock resolves a tracked gem OLDER than a consumer's, wired into `consumer-ci.yml` after the consumer bundle install. That lane is the only place two repos' lockfiles exist at once (engine at `studio/`, consumer beside it); `test/lib/consumer_ci_shard_contract_test.rb` records the same constraint for its own cross-repo contract. Direction is ONE-WAY on purpose: engine behind FAILS, engine level or ahead passes (the engine is the producer and may test an unreleased gem), and a consumer bundling no tracked gem is a SKIP, not a failure — `mcritchie_industries` is the base half working as designed. Stdlib-only, and it names its one-command remedy (`bundle update solana-studio`) in the failure.
|
|
@@ -217,6 +272,45 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
217
272
|
|
|
218
273
|
### Fixed
|
|
219
274
|
|
|
275
|
+
- **The style guide's two "Sign Wallet" thumbnails no longer crown themselves
|
|
276
|
+
with a padlock the card stopped drawing, and the guide's lock no longer trails
|
|
277
|
+
the release that removed it.** solana-studio 0.6.1 replaced the step-up card's
|
|
278
|
+
padlock with the remembered wallet's own brand mark, falling back to a neutral
|
|
279
|
+
billfold where no brand is remembered. Both hand-drawn thumbnails in
|
|
280
|
+
`style/_modals` went on drawing the padlock, so the first thing a designer
|
|
281
|
+
reads advertised a glyph the design system had retired.
|
|
282
|
+
|
|
283
|
+
**THE THUMBNAILS WERE HALF OF IT.** The opened specimen renders the REAL
|
|
284
|
+
shared partial, but it does so through whatever `Gemfile.lock` resolves — so
|
|
285
|
+
it is only ever as current as the lock, and the lock sat on 0.6.0 while
|
|
286
|
+
turf-monster shipped 0.6.1. The guide's opened card was still drawing the
|
|
287
|
+
padlock too, which `bin/gem-drift-check` had already been failing on
|
|
288
|
+
(`engine 0.6.0 TRAILS turf-monster 0.6.1`). Correcting the sketches alone
|
|
289
|
+
would have put a brand mark on the thumbnail and a padlock in the card it
|
|
290
|
+
opens — moving the contradiction onto one screen rather than removing it. So
|
|
291
|
+
the lock had to move too, and it has: `accepted` carried the engine to
|
|
292
|
+
solana-studio 0.7.0 while this change sat in review, which is past the 0.6.1
|
|
293
|
+
that removed the padlock. This change no longer moves the lock itself — it
|
|
294
|
+
defers to the line `accepted` already holds — so the sketch and the card it
|
|
295
|
+
opens agree without it.
|
|
296
|
+
|
|
297
|
+
The sketches follow the card: a centered brand tile where a wallet is
|
|
298
|
+
remembered, the card's own billfold outline in an inset square where none is.
|
|
299
|
+
They stay SKETCHES rather than real brand marks for a mechanical reason worth
|
|
300
|
+
recording — every `se-wallet` symbol on the guide is defined inside a
|
|
301
|
+
`template x-if`, whose content is inert until Alpine clones it, so a `use`
|
|
302
|
+
placed outside one resolves to nothing and paints an empty box.
|
|
303
|
+
|
|
304
|
+
Guarded by `test/views/step_up_specimen_thumbnails_test.rb`, which slices each
|
|
305
|
+
thumbnail by a `data-test` hook before asserting. That slicing is the point:
|
|
306
|
+
the rendered guide already carried 17 `se-wallet-` hits from the picker and
|
|
307
|
+
the card's own CTA, so a page-wide assertion for the mark could not fail, and
|
|
308
|
+
a page-wide assertion against the padlock could not pass — the drag-board
|
|
309
|
+
specimen's prose legitimately contains one. The padlock is pinned in BOTH the
|
|
310
|
+
codepoint and the HTML-entity form. MEASURED: five mutants, five killed —
|
|
311
|
+
the padlock restored in either form, either header mark removed, and the lock
|
|
312
|
+
reverted to 0.6.0 each reddens the assertion that owns it.
|
|
313
|
+
|
|
220
314
|
- **The style guide's page-scoped modal store now resolves animations through
|
|
221
315
|
the LIVE registry, so the guide can no longer disagree with itself.**
|
|
222
316
|
`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
|
|
@@ -182,186 +182,295 @@
|
|
|
182
182
|
body { overflow-anchor: none; }
|
|
183
183
|
</style>
|
|
184
184
|
<script>
|
|
185
|
-
// studio-engine:
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
// re-attaches after Turbo nav.
|
|
185
|
+
// studio-engine: THE PINNED STACK. Publishes the live geometry of every layer
|
|
186
|
+
// of pinned page chrome as CSS custom properties, so anything fixed or sticky
|
|
187
|
+
// positions off it in CSS with no hardcoded px and no JS of its own.
|
|
189
188
|
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
189
|
+
// A layer joins by carrying `data-pin="<name>"`. Nothing else is required:
|
|
190
|
+
// no registration call, no declared stacking order, no consumer edit.
|
|
192
191
|
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
192
|
+
// WHAT A CONSUMER READS — prefer the first two; they are the reason this is a
|
|
193
|
+
// primitive rather than an arithmetic helper:
|
|
194
|
+
//
|
|
195
|
+
// --pin-stack-bottom the bottom of the WHOLE stack. What something sitting
|
|
196
|
+
// BENEATH all the pinned chrome positions off.
|
|
197
|
+
// --pin-<name>-top the bottom of everything ABOVE that layer. What a layer
|
|
198
|
+
// that is ITSELF in the stack positions off.
|
|
199
|
+
// --pin-<name>-h that layer's HEIGHT.
|
|
200
|
+
// --pin-<name>-bottom that layer's own BOTTOM EDGE, in viewport coordinates.
|
|
201
|
+
// --nav-h / --nav-bottom the <header>'s height and bottom edge, under their
|
|
202
|
+
// legacy names, from the same measurement.
|
|
203
|
+
//
|
|
204
|
+
// DO NOT COMPOSE THE STACK IN A CONSUMER. `top: max(var(--pin-nav-bottom),
|
|
205
|
+
// var(--pin-apps-bottom))` is what this replaced, and it fails twice: it does
|
|
206
|
+
// not scale, because a fourth layer means editing every consumer that ever
|
|
207
|
+
// wanted to sit under the stack; and it is not sound, because a max() over two
|
|
208
|
+
// custom properties is only meaningful if they were written in the same frame.
|
|
209
|
+
// --pin-stack-bottom is one value that cannot disagree with itself.
|
|
210
|
+
//
|
|
211
|
+
// --nav-h AND --nav-bottom ARE NOT THE SAME NUMBER. They are equal only when
|
|
212
|
+
// the header sits at the top of the viewport, which is why one was long
|
|
213
|
+
// mistaken for the other. An app that stacks chrome ABOVE the header — an
|
|
214
|
+
// environment banner rendered before the navbar, as mcritchie-industries does —
|
|
215
|
+
// pushes the header down, and a `fixed` overlay positioned at --nav-h then
|
|
216
|
+
// rides UP over the header by exactly the height of that chrome. Use --nav-h to
|
|
217
|
+
// SIZE something as tall as the header; use --nav-bottom to START something
|
|
218
|
+
// underneath it. Same distinction for --pin-<name>-h vs --pin-<name>-bottom.
|
|
200
219
|
(function () {
|
|
201
220
|
if (!window.ResizeObserver) return;
|
|
202
221
|
var ro = null;
|
|
203
222
|
var queued = false;
|
|
204
|
-
|
|
205
|
-
//
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
//
|
|
223
|
+
// WHAT WAS LAST PUBLISHED, per layer name: { h, bottom, top }. It does two
|
|
224
|
+
// jobs. It is the record of which names this publisher has written, so a
|
|
225
|
+
// DEPARTED layer can be cleared. And it is what lets an UNCHANGED value skip
|
|
226
|
+
// its write — these are INHERITED custom properties on documentElement, so
|
|
227
|
+
// even a write of the same number invalidates style for the whole document,
|
|
228
|
+
// and a scroll frame touches this four times per layer.
|
|
229
|
+
var last = {};
|
|
230
|
+
var lastStack = null;
|
|
231
|
+
// SKIPPING AN UNCHANGED WRITE IS ONLY SAFE WHILE NOTHING ELSE TOUCHES THESE
|
|
232
|
+
// PROPERTIES. On a scroll frame that holds, and skipping is most of why this
|
|
233
|
+
// is cheap. On a STRUCTURAL change it does not: a layer arriving or leaving,
|
|
234
|
+
// a Turbo patch, a fresh document — anything there may have cleared a
|
|
235
|
+
// property out from under the cache, and a skip would then never write it
|
|
236
|
+
// again. So a structural change forces a full write, once.
|
|
237
|
+
var forceWrite = true;
|
|
238
|
+
// The nodes currently under observation, held only to know what to unobserve
|
|
239
|
+
// when one departs. The REGISTRY ITSELF is never cached; see readPins().
|
|
240
|
+
var observed = [];
|
|
241
|
+
|
|
242
|
+
// THE REGISTRY IS DERIVED, NEVER CACHED — and that is a correctness rule.
|
|
209
243
|
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
244
|
+
// This used to build a `pins` array once and hold each element by reference
|
|
245
|
+
// until something re-registered it. A Turbo Stream replaces a pinned layer's
|
|
246
|
+
// node WITHOUT a turbo:load, so between the patch and the re-scan the held
|
|
247
|
+
// reference pointed at the DETACHED predecessor — and a detached node's rect
|
|
248
|
+
// is all zeros, which is indistinguishable from a layer that is legitimately
|
|
249
|
+
// hidden. Measured on mcritchie-studio's /deployments: --pin-apps-bottom
|
|
250
|
+
// published 0px while the live strip stood at display:block, bottom 152px,
|
|
251
|
+
// and the board's lane headers slammed 99px up and back on every broadcast.
|
|
218
252
|
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
|
|
224
|
-
function readPin(pin) {
|
|
225
|
-
return {
|
|
226
|
-
pin: pin,
|
|
227
|
-
h: pin.el.offsetHeight,
|
|
228
|
-
bottom: Math.max(0, pin.el.getBoundingClientRect().bottom)
|
|
229
|
-
};
|
|
230
|
-
}
|
|
231
|
-
function writeReading(r) {
|
|
232
|
-
var style = document.documentElement.style;
|
|
233
|
-
var pin = r.pin;
|
|
234
|
-
if (r.h !== pin.lastH) {
|
|
235
|
-
pin.lastH = r.h;
|
|
236
|
-
style.setProperty('--pin-' + pin.name + '-h', r.h + 'px');
|
|
237
|
-
if (pin.legacy) style.setProperty('--nav-h', r.h + 'px');
|
|
238
|
-
}
|
|
239
|
-
if (r.bottom !== pin.lastBottom) {
|
|
240
|
-
pin.lastBottom = r.bottom;
|
|
241
|
-
style.setProperty('--pin-' + pin.name + '-bottom', r.bottom + 'px');
|
|
242
|
-
if (pin.legacy) style.setProperty('--nav-bottom', r.bottom + 'px');
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
function publishAll() {
|
|
246
|
-
var readings = [];
|
|
247
|
-
var i;
|
|
248
|
-
for (i = 0; i < pins.length; i++) readings.push(readPin(pins[i]));
|
|
249
|
-
for (i = 0; i < readings.length; i++) writeReading(readings[i]);
|
|
250
|
-
}
|
|
251
|
-
function schedule() {
|
|
252
|
-
if (queued || !pins.length) return;
|
|
253
|
-
queued = true;
|
|
254
|
-
window.requestAnimationFrame(function () { queued = false; publishAll(); });
|
|
255
|
-
}
|
|
256
|
-
// ONE construction site. Two of them let attach() replace the observer that
|
|
257
|
-
// registerPins() had already put the pins on, silently dropping every live
|
|
258
|
-
// observation — caught by test_pins_share_one_observer_and_one_frame, which
|
|
259
|
-
// counts them for exactly that reason.
|
|
260
|
-
function ensureObserver() {
|
|
261
|
-
if (!ro) ro = new ResizeObserver(schedule);
|
|
262
|
-
return ro;
|
|
263
|
-
}
|
|
264
|
-
function registerPins() {
|
|
265
|
-
ensureObserver();
|
|
266
|
-
pins = [];
|
|
267
|
-
var seen = {};
|
|
253
|
+
// Re-querying costs one querySelectorAll over a handful of nodes per publish.
|
|
254
|
+
// That is cheaper than the class of bug it removes, because it makes a stale
|
|
255
|
+
// reference IMPOSSIBLE rather than merely unlikely: there is no window between
|
|
256
|
+
// a DOM patch and a re-scan in which this can be pointed at the wrong node.
|
|
257
|
+
function readPins() {
|
|
268
258
|
var header = document.querySelector('header');
|
|
269
|
-
var headerPinned = false;
|
|
270
259
|
var nodes = document.querySelectorAll('[data-pin]');
|
|
260
|
+
var list = [];
|
|
261
|
+
var headerPinned = false;
|
|
271
262
|
for (var i = 0; i < nodes.length; i++) {
|
|
272
263
|
var name = nodes[i].getAttribute('data-pin');
|
|
273
264
|
// An unnamed pin would write `--pin--h`, a valid property name and a
|
|
274
265
|
// silent nonsense one. Skip it rather than publish it.
|
|
275
266
|
if (!name) continue;
|
|
276
|
-
seen[name] = true;
|
|
277
267
|
if (nodes[i] === header) headerPinned = true;
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
268
|
+
list.push(measure(nodes[i], name, nodes[i] === header));
|
|
269
|
+
}
|
|
270
|
+
// THE HOST-OWNED HEADER, and the back-compat promise that depends on it.
|
|
271
|
+
// A header that does not carry [data-pin] is not in the registry, so the
|
|
272
|
+
// legacy --nav-h / --nav-bottom — which studio/_sidebar_panel, turf's
|
|
273
|
+
// contest board and mcritchie-studio's heartbeat CSS all read — would
|
|
274
|
+
// never publish at all. Both live consumers own their header, so it is
|
|
275
|
+
// adopted as a pin even when it does not ask to be.
|
|
276
|
+
if (header && !headerPinned) list.push(measure(header, 'nav', true));
|
|
277
|
+
return list;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ONE READ PER ELEMENT PER PUBLISH, and every read before every write.
|
|
281
|
+
// A read after a write to the root forces a fresh layout, which is the
|
|
282
|
+
// thrash this publisher exists without. Measured by review at 6x CPU
|
|
283
|
+
// throttle over 175 frames: frames past 20ms 62 vs 42, median 17.7 vs
|
|
284
|
+
// 14.9ms, p90 34.2 vs 26.0ms; at 10x, 84/180 vs 4/180.
|
|
285
|
+
function measure(el, name, legacy) {
|
|
286
|
+
// A hidden layer needs no special case: display:none gives offsetHeight 0
|
|
287
|
+
// and an all-zero rect, so it measures 0 and drops out of the stack on its
|
|
288
|
+
// own. That is what lets a layer come and go without anyone declaring a
|
|
289
|
+
// stacking order, and it is load-bearing rather than incidental.
|
|
290
|
+
var r = el.getBoundingClientRect();
|
|
291
|
+
return { el: el, name: name, legacy: legacy, h: el.offsetHeight, bottom: Math.max(0, r.bottom) };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// THE STACK, COMPOSED HERE RATHER THAN BY EVERY CONSUMER.
|
|
295
|
+
//
|
|
296
|
+
// Consumers used to write `top: max(var(--pin-nav-bottom), var(--pin-apps-bottom))`
|
|
297
|
+
// — every layer enumerated, in every consumer. That is two failures at once.
|
|
298
|
+
// It does not SCALE: a fourth pinned layer means editing every consumer that
|
|
299
|
+
// ever wanted to sit under the stack. And it is not SOUND: max() over two
|
|
300
|
+
// independently-written custom properties is only meaningful if they were
|
|
301
|
+
// written in the same frame, and any skew between them is expressed instantly
|
|
302
|
+
// as layout. Composing here means a consumer reads ONE value that cannot
|
|
303
|
+
// disagree with itself.
|
|
304
|
+
//
|
|
305
|
+
// --pin-stack-bottom the bottom of the WHOLE pinned stack. What something
|
|
306
|
+
// sitting beneath all the chrome positions off.
|
|
307
|
+
// --pin-<name>-top the bottom of everything ABOVE that layer. What a
|
|
308
|
+
// layer that is ITSELF in the stack positions off —
|
|
309
|
+
// --pin-stack-bottom would include the layer's own
|
|
310
|
+
// edge, so a layer positioned off it would chase itself
|
|
311
|
+
// down the page.
|
|
312
|
+
//
|
|
313
|
+
// ORDER is document order, which is the order the layers were declared and
|
|
314
|
+
// therefore the order they read in. `data-pin-order` overrides it for a layer
|
|
315
|
+
// whose DOM position does not match where it sits on screen; it is a plain
|
|
316
|
+
// number, lower is higher up, and it needs to exist on only the layer that
|
|
317
|
+
// disagrees rather than on all of them.
|
|
318
|
+
function order(p) {
|
|
319
|
+
var raw = parseFloat(p.el.getAttribute('data-pin-order'));
|
|
320
|
+
return isNaN(raw) ? null : raw;
|
|
321
|
+
}
|
|
322
|
+
function composed(pins) {
|
|
323
|
+
var i, j, stack = 0;
|
|
324
|
+
for (i = 0; i < pins.length; i++) {
|
|
325
|
+
if (pins[i].bottom > stack) stack = pins[i].bottom;
|
|
326
|
+
var above = 0;
|
|
327
|
+
for (j = 0; j < pins.length; j++) {
|
|
328
|
+
if (i === j) continue;
|
|
329
|
+
var oi = order(pins[i]), oj = order(pins[j]);
|
|
330
|
+
// Both ordered: compare the numbers. Otherwise fall back to document
|
|
331
|
+
// order, which querySelectorAll already returns them in.
|
|
332
|
+
var jIsAbove = (oi !== null && oj !== null) ? oj < oi : j < i;
|
|
333
|
+
if (jIsAbove && pins[j].bottom > above) above = pins[j].bottom;
|
|
334
|
+
}
|
|
335
|
+
pins[i].top = above;
|
|
336
|
+
}
|
|
337
|
+
return stack;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function write(pins, stack) {
|
|
341
|
+
var style = document.documentElement.style;
|
|
342
|
+
var seen = {};
|
|
343
|
+
var i, p, prev;
|
|
344
|
+
// A ROSTER CHANGE IS STRUCTURAL TOO, and it is not always announced by an
|
|
345
|
+
// event: an x-show that adds the node, a layer rendered by something other
|
|
346
|
+
// than a stream. Compare the names against what was published last.
|
|
347
|
+
var force = forceWrite;
|
|
348
|
+
forceWrite = false;
|
|
349
|
+
if (!force) {
|
|
350
|
+
for (i = 0; i < pins.length; i++) { if (!last.hasOwnProperty(pins[i].name)) { force = true; break; } }
|
|
288
351
|
}
|
|
352
|
+
for (i = 0; i < pins.length; i++) {
|
|
353
|
+
p = pins[i];
|
|
354
|
+
seen[p.name] = true;
|
|
355
|
+
prev = last[p.name] || {};
|
|
356
|
+
if (force || p.h !== prev.h) {
|
|
357
|
+
style.setProperty('--pin-' + p.name + '-h', p.h + 'px');
|
|
358
|
+
if (p.legacy) style.setProperty('--nav-h', p.h + 'px');
|
|
359
|
+
}
|
|
360
|
+
if (force || p.bottom !== prev.bottom) {
|
|
361
|
+
style.setProperty('--pin-' + p.name + '-bottom', p.bottom + 'px');
|
|
362
|
+
if (p.legacy) style.setProperty('--nav-bottom', p.bottom + 'px');
|
|
363
|
+
}
|
|
364
|
+
if (force || p.top !== prev.top) style.setProperty('--pin-' + p.name + '-top', p.top + 'px');
|
|
365
|
+
last[p.name] = { h: p.h, bottom: p.bottom, top: p.top };
|
|
366
|
+
}
|
|
367
|
+
if (force || stack !== lastStack) {
|
|
368
|
+
style.setProperty('--pin-stack-bottom', stack + 'px');
|
|
369
|
+
lastStack = stack;
|
|
370
|
+
}
|
|
371
|
+
|
|
289
372
|
// CLEAR WHAT LEFT. A layer goes away three ways: display:none and x-show
|
|
290
|
-
// both keep the node, so it measures 0 and drops out of
|
|
291
|
-
//
|
|
292
|
-
// last published value stands forever
|
|
373
|
+
// both keep the node, so it measures 0 and drops out of the stack on its
|
|
374
|
+
// own. REMOVAL does not — nothing is left to measure, and without this the
|
|
375
|
+
// last published value stands forever while the consumer sits at the height
|
|
293
376
|
// of a strip that is gone. Review measured a removed 300px strip holding
|
|
294
|
-
// --pin-apps-bottom at 300px against a real stack bottom of 160px
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
377
|
+
// --pin-apps-bottom at 300px against a real stack bottom of 160px.
|
|
378
|
+
for (var name in last) {
|
|
379
|
+
if (last.hasOwnProperty(name) && !seen[name]) {
|
|
380
|
+
style.removeProperty('--pin-' + name + '-h');
|
|
381
|
+
style.removeProperty('--pin-' + name + '-bottom');
|
|
382
|
+
style.removeProperty('--pin-' + name + '-top');
|
|
383
|
+
delete last[name];
|
|
301
384
|
}
|
|
302
385
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
// Both apps pin a two-segment ~> under 1.0, so it would have landed on
|
|
319
|
-
// their next bundle update with no floor bump to warn anyone.
|
|
320
|
-
//
|
|
321
|
-
// So the header is adopted as a pin even when it does not ask to be. It
|
|
322
|
-
// still publishes --pin-nav-* under that name, which is what an app gets
|
|
323
|
-
// for free the moment it wants the general contract.
|
|
324
|
-
if (header && !headerPinned) {
|
|
325
|
-
pins.push({
|
|
326
|
-
el: header,
|
|
327
|
-
name: 'nav',
|
|
328
|
-
legacy: true,
|
|
329
|
-
lastH: null,
|
|
330
|
-
lastBottom: null
|
|
331
|
-
});
|
|
332
|
-
seen.nav = true;
|
|
333
|
-
ensureObserver().observe(header);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function publishAll() {
|
|
389
|
+
var pins = readPins();
|
|
390
|
+
write(pins, composed(pins));
|
|
391
|
+
syncObserver(pins);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Observe whatever is pinned RIGHT NOW. observe() on an already-observed node
|
|
395
|
+
// is a no-op, so this only ever adds the new; unobserve() drops the departed
|
|
396
|
+
// so a replaced node cannot keep waking us from outside the document.
|
|
397
|
+
function syncObserver(pins) {
|
|
398
|
+
if (!pins.length) {
|
|
399
|
+
if (ro) { ro.disconnect(); ro = null; observed = []; }
|
|
400
|
+
return;
|
|
334
401
|
}
|
|
402
|
+
if (!ro) ro = new ResizeObserver(onResize);
|
|
403
|
+
var i, next = [];
|
|
404
|
+
for (i = 0; i < pins.length; i++) { next.push(pins[i].el); ro.observe(pins[i].el); }
|
|
405
|
+
for (i = 0; i < observed.length; i++) {
|
|
406
|
+
if (next.indexOf(observed[i]) === -1) ro.unobserve(observed[i]);
|
|
407
|
+
}
|
|
408
|
+
observed = next;
|
|
409
|
+
}
|
|
335
410
|
|
|
336
|
-
|
|
411
|
+
// PUBLISH IN THE ResizeObserver CALLBACK, SYNCHRONOUSLY. This is the whole
|
|
412
|
+
// fix, and it is a statement about WHEN a frame does its work rather than
|
|
413
|
+
// about what this code computes.
|
|
414
|
+
//
|
|
415
|
+
// A frame runs: rAF callbacks -> style/layout -> ResizeObserver callbacks ->
|
|
416
|
+
// paint. This publisher used to be woken by the observer and then DEFER the
|
|
417
|
+
// write to requestAnimationFrame, which lands at the top of the NEXT frame —
|
|
418
|
+
// so the frame in which a layer actually appeared or disappeared painted with
|
|
419
|
+
// the previous frame's number, every time. The RO callback runs after layout
|
|
420
|
+
// and before paint, which is exactly the last moment a write can still land
|
|
421
|
+
// in the frame that caused it.
|
|
422
|
+
//
|
|
423
|
+
// A/B measured in isolation, toggling a pinned layer's display and sampling
|
|
424
|
+
// what the changing frame paints: rAF-deferred wrong on 8/8 changing frames,
|
|
425
|
+
// RO-synchronous wrong on 0/8.
|
|
426
|
+
//
|
|
427
|
+
// This cannot loop. The values written here move CONSUMERS, and a consumer is
|
|
428
|
+
// not a pin — nothing this writes resizes anything it observes. A pinned layer
|
|
429
|
+
// that positions off --pin-<name>-top MOVES without RESIZING, and the observer
|
|
430
|
+
// fires on size, so it settles in one pass rather than ringing.
|
|
431
|
+
// The one way a SYNCHRONOUS publish could misbehave: a consumer that both
|
|
432
|
+
// reads a published value and is itself a pin, sized off what it reads
|
|
433
|
+
// (`height: calc(100vh - var(--pin-apps-top))`). That resizes an observed
|
|
434
|
+
// node from inside the observer's own callback, and left alone the browser
|
|
435
|
+
// reports "ResizeObserver loop completed with undelivered notifications" and
|
|
436
|
+
// drops the frame. So re-entry is counted, and past a couple of settling
|
|
437
|
+
// passes this hands the rest to the frame callback: the pathological page
|
|
438
|
+
// degrades to the OLD one-frame-late behaviour instead of wedging, and the
|
|
439
|
+
// ordinary page never reaches the branch. Reset on the next frame, so the
|
|
440
|
+
// count measures one frame's settling rather than the session's.
|
|
441
|
+
var depth = 0, resetQueued = false;
|
|
442
|
+
function onResize() {
|
|
443
|
+
if (depth >= 3) { schedule(); return; }
|
|
444
|
+
depth++;
|
|
445
|
+
if (!resetQueued) {
|
|
446
|
+
resetQueued = true;
|
|
447
|
+
window.requestAnimationFrame(function () { resetQueued = false; depth = 0; });
|
|
448
|
+
}
|
|
337
449
|
publishAll();
|
|
338
450
|
}
|
|
339
|
-
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
// only reachable from that branch before, so pins on a headerless layout were
|
|
349
|
-
// permanently dead.
|
|
350
|
-
function attach() {
|
|
351
|
-
if (ro) ro.disconnect();
|
|
352
|
-
registerPins();
|
|
353
|
-
if (!pins.length && ro) { ro.disconnect(); ro = null; }
|
|
451
|
+
|
|
452
|
+
// SCROLL STILL NEEDS THE FRAME CALLBACK. A scroll changes a sticky header's
|
|
453
|
+
// bottom EDGE without changing its size, so the observer never sees it and
|
|
454
|
+
// there is nothing to be synchronous with — rAF is the right clock, and it
|
|
455
|
+
// coalesces the burst iOS momentum fires far above 60Hz.
|
|
456
|
+
function schedule() {
|
|
457
|
+
if (queued) return;
|
|
458
|
+
queued = true;
|
|
459
|
+
window.requestAnimationFrame(function () { queued = false; publishAll(); });
|
|
354
460
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
//
|
|
461
|
+
|
|
462
|
+
function invalidate() { forceWrite = true; publishAll(); }
|
|
463
|
+
document.addEventListener('DOMContentLoaded', invalidate);
|
|
464
|
+
document.addEventListener('turbo:load', invalidate);
|
|
465
|
+
// A Turbo Stream replaces nodes WITHOUT a turbo:load. The registry is derived
|
|
466
|
+
// per publish, so a stale reference is no longer possible either way — but the
|
|
467
|
+
// OBSERVER still has to be moved onto the incoming node, and the stack has to
|
|
468
|
+
// be recomposed for a layer that arrived or left. Both happen in publishAll.
|
|
360
469
|
document.addEventListener('turbo:before-stream-render', function () {
|
|
361
|
-
window.requestAnimationFrame(
|
|
470
|
+
window.requestAnimationFrame(invalidate);
|
|
362
471
|
});
|
|
363
|
-
// Registered ONCE at script eval, not inside
|
|
364
|
-
// re-
|
|
472
|
+
// Registered ONCE at script eval, not inside a re-scan, so a Turbo nav
|
|
473
|
+
// re-publishes instead of stacking another listener per visit.
|
|
365
474
|
window.addEventListener('scroll', schedule, { passive: true });
|
|
366
475
|
window.addEventListener('resize', schedule, { passive: true });
|
|
367
476
|
})();
|
|
@@ -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.3
|
|
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
|