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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6827dbf6d947e68f03792d54208e63d7132d87291d41c9f25a79a2b375f7c337
4
- data.tar.gz: 6208b4449236e9e5a634a99a0f1634a28d53096ee11ab7a53180d41f3e2fc31e
3
+ metadata.gz: 13abdd4ef0f10262a6560c40b2bed302c16d9f3b3b21582f3569cf0a7517cdf8
4
+ data.tar.gz: 6f47a52a0ba75d8be96ff6200b75d30a1ff49d76db401581a17a0ea88b2dcd6c
5
5
  SHA512:
6
- metadata.gz: 214d5859c1c05d13b11c5101ae403121b4874540cca544b201cc5c0d158f6fcef6936e9c9b038ccdc63a5221face3ad2e1f9a12e79406f0cfa741027c7246be8
7
- data.tar.gz: 970cee9c4cea2c9fff4210930161e3565fbcd57cb02c4a86f82682e8bf9eca41f6c9ff07986939d37a270d66e4bbbce8c5788e3362e8f6c73936ca9ddf0a4130
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 TRAILS a consumer's, and consumer-ci.yml runs it — the only lane holding
79
- # two repos' lockfiles at once. Direction is one-way: engine behind fails, engine
80
- # ahead or level passes, a consumer bundling no solana-studio is a skip. Keeping
81
- # up is now enforced rather than remembered, which is what lets the floor stay a
82
- # floor. Guarded by test/lib/gem_drift_check_test.rb.
78
+ # lock resolves an OLDER solana-studio than a consumer's, and consumer-ci.yml
79
+ # runs it — the only lane holding two repos' lockfiles at once. Direction is
80
+ # one-way: engine behind fails, engine ahead or level passes, a consumer
81
+ # bundling no solana-studio is a skip. Guarded by
82
+ # test/lib/gem_drift_check_test.rb.
83
+ #
84
+ # READ THE CONSUMER AS A MESSENGER, not as the target. The invariant is that
85
+ # this engine resolves the LATEST RELEASED solana-studio. A consumer's lock is
86
+ # compared only because it is the one newer-release fact on disk — the gate is
87
+ # stdlib-only and makes no network call, deliberately, so it cannot ask the
88
+ # registry directly. A consumer being ahead is EVIDENCE of a release this engine
89
+ # missed, never itself the thing to catch up to.
90
+ #
91
+ # AND THE GATE ALONE WAS NOT ENOUGH. Enforcement only reddens; a human still had
92
+ # to do the bump. Measured 2026-09-07: that human was late five times in ONE DAY
93
+ # — by 3h, 2h20m, 14h15m and 1h10m — and the worst case was not the longest but
94
+ # the fastest, a lock that went stale 36 minutes after the previous fix was
95
+ # committed. While it is stale EVERY open engine PR is red, over a line no PR
96
+ # author owns. So .github/dependabot.yml now opens the bump PR daily (scoped to
97
+ # solana-studio ALONE, so this repo never inherits the third-party graveyard),
98
+ # and .github/workflows/engine-lock-automerge.yml merges it when — and only
99
+ # when — all four of bin/lock-bump-mergeable's conditions hold. A breaking bump
100
+ # still stops, stays open and red, and still waits for a human.
83
101
  group :development, :test do
84
102
  gem "solana-studio", ">= 0.5.3"
85
103
  end
data/README.md CHANGED
@@ -305,6 +305,18 @@ the one credential that skips attestation) and `props.submitting`. It receives
305
305
  one local, `modal_store`: the engine's real host is `"modals"`, the living style
306
306
  guide's page-scoped host is `"dsModals"`.
307
307
 
308
+ **Name a store like a JavaScript identifier.** Partials splice this local in as a
309
+ bare name — `$store.<name>.current()` — rather than as a string, so a value
310
+ carrying a quote, a dot or a space is not a mangled store name: it is a
311
+ SyntaxError in the whole `x-data`, and Alpine mounts a component that renders
312
+ every element and does nothing. Escaping is not the repair (an escaped identifier
313
+ is a different SyntaxError); a name that matches `/\A[A-Za-z_$][A-Za-z0-9_$]*\z/`
314
+ is. `studio/modals/onboarding/_first_name` enforces exactly that and raises
315
+ `ArgumentError` on anything else — the first partial to do so, not yet the fleet.
316
+ Note the shape decides the repair, not the name of the local: `blocks/_birthday`
317
+ and `blocks/_leveling_activity` pass `modal_store` in *string* position and
318
+ correctly `escape_javascript` it instead.
319
+
308
320
  It also keeps two blocks the gem renders **by name** across the gem boundary:
309
321
  `studio/modals/blocks/wallet_brand_sprite` and `studio/modals/blocks/card_header`.
310
322
  Renaming either is a cross-repo change.
@@ -45,6 +45,17 @@ module Studio
45
45
  def first_name
46
46
  value = params[:first_name].to_s.strip.gsub(/\s+/, " ")
47
47
 
48
+ # THE SAME SENTENCE AS THE CARD'S EMPTY-FIELD ERROR, and deliberately NOT
49
+ # parameterised the way that one now is. `required` is a RENDER-TIME local of
50
+ # studio/modals/onboarding/_first_name; the server is never told which mode
51
+ # the card was drawn in, so this branch has no `required` to follow, and
52
+ # rewording it unconditionally would move McRitchie Studio's copy to say
53
+ # something only turf's gate needs.
54
+ #
55
+ # It is also UNREACHABLE FROM THE CARD: save() returns on a blank value
56
+ # before it posts, so what arrives here is a direct POST or a host driving
57
+ # these endpoints with its own form. If that ever becomes a mode-sensitive
58
+ # surface, the caller says which mode it is in — this string does not guess.
48
59
  if value.blank?
49
60
  return refuse("Enter your first name, or skip for now.")
50
61
  end
@@ -182,186 +182,295 @@
182
182
  body { overflow-anchor: none; }
183
183
  </style>
184
184
  <script>
185
- // studio-engine: publish the sticky header's live geometry as CSS custom
186
- // properties so fixed/sticky elements can position off it without hardcoded
187
- // px. Tracks every resize, including scroll-collapse animations, and
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
- // --nav-h the header's HEIGHT
191
- // --nav-bottom the header's BOTTOM EDGE, in viewport coordinates
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
- // They are equal only when the header sits at the top of the viewport, which
194
- // is why one was long mistaken for the other. An app that stacks chrome ABOVE
195
- // the header — an environment banner rendered before the navbar, as
196
- // mcritchie-industries does pushes the header down, and a `fixed` overlay
197
- // positioned at --nav-h then rides UP over the header by exactly the height of
198
- // that chrome. Use --nav-h to size something as tall as the header; use
199
- // --nav-bottom to start something underneath it.
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
- var pins = [];
205
- // The names this publisher has written, so a DEPARTED layer can be cleared.
206
- var published = {};
207
-
208
- // ONE MEASUREMENT PER ELEMENT PER FRAME, and every read before every write.
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
- // publish() and publishPin() each kept reads-before-writes INTERNALLY, and
211
- // that was not enough: the frame ran publish(current) which WRITES two
212
- // inherited custom properties on documentElement and then the pin loop,
213
- // whose first act is a getBoundingClientRect(). A read after a write to the
214
- // root forces a fresh layout, so the thrash this file removed came straight
215
- // back one function boundary over. Measured by review at 6x CPU throttle
216
- // over 175 frames, pin loop on vs off: frames past 20ms 62 vs 42, median
217
- // 17.7 vs 14.9ms, p90 34.2 vs 26.0ms; at 10x, 84/180 vs 4/180.
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
- // The header was also measured TWICE once as `current`, once as pin
220
- // `nav` for values proven identical across 40 frames. It is a pin like
221
- // any other now; the only thing special about it is that it ALSO writes the
222
- // legacy --nav-h / --nav-bottom that studio/_sidebar_panel and
223
- // turf-monster's scroll-margin-top already read.
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
- pins.push({
279
- el: nodes[i],
280
- name: name,
281
- // The header keeps publishing --nav-h / --nav-bottom, from the SAME
282
- // measurement, so it is never measured twice.
283
- legacy: nodes[i] === header,
284
- lastH: null,
285
- lastBottom: null
286
- });
287
- ro.observe(nodes[i]);
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 a consumer's
291
- // max() on its own. REMOVAL does not — nothing is left to measure, the
292
- // last published value stands forever, and a consumer sits at the height
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: a
295
- // permanent 140px error, and a direct contradiction of this primitive's
296
- // headline promise.
297
- for (var name2 in published) {
298
- if (published.hasOwnProperty(name2) && !seen[name2]) {
299
- document.documentElement.style.removeProperty('--pin-' + name2 + '-h');
300
- document.documentElement.style.removeProperty('--pin-' + name2 + '-bottom');
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
- // THE HOST-OWNED HEADER, and the back-compat promise that depends on it.
304
- //
305
- // The registry is built from [data-pin], so a header that does not carry
306
- // the attribute is not in it — and --nav-h / --nav-bottom, which are
307
- // written from the legacy pin's reading, would never publish at all.
308
- // THIS ENGINE'S navbar carries data-pin, so every test here and the whole
309
- // e2e lab publish fine; the broken path exists only in a consumer that
310
- // owns its header, which is BOTH live consumers:
311
- //
312
- // turf-monster layouts/_navbar.html.erb (data-navbar-root)
313
- // mcritchie-studio layouts/application.html.erb (inline)
314
- //
315
- // Review measured it against turf's real header and its real sidebar
316
- // declaration: --nav-h and --nav-bottom unset, the gear drawer 82px out
317
- // of place, the contest board 114px, and neither tracking the collapse.
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
- published = seen;
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
- // ATTACH IS NOW JUST A RE-SCAN. The header used to be tracked separately as
340
- // `current`, with its own observe and its own publish; it is a pin named
341
- // "nav" like any other, so registerPins() covers it. What survives is the
342
- // release promise: a page with no pinned chrome at all disconnects the
343
- // observer rather than leaving it on detached nodes, and the properties keep
344
- // their last value rather than being driven to 0 by an all-zero rect.
345
- //
346
- // A page that never has a <header> is served too — the observer is built by
347
- // ensureObserver() from registerPins(), not from a has-header branch. It was
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
- document.addEventListener('DOMContentLoaded', attach);
356
- document.addEventListener('turbo:load', attach);
357
- // A Turbo Stream replaces nodes WITHOUT a turbo:load, which is how a
358
- // captured pin goes stale. Re-register on the next frame, once the stream
359
- // has actually patched the DOM.
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(registerPins);
470
+ window.requestAnimationFrame(invalidate);
362
471
  });
363
- // Registered ONCE at script eval, not inside attach(), so a Turbo nav
364
- // re-scans instead of stacking another listener per visit.
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
- skip_path — POST target for the skip (default "/onboarding/skip_first_name")
13
+ submit_path — POST target for the name (default "/onboarding/first_name").
14
+ Interpolated into the x-data, so it is JS-escaped on the way in
15
+ — see THE TWO SHAPES below.
16
+ skip_path — POST target for the skip (default "/onboarding/skip_first_name").
17
+ JS-escaped, same as submit_path.
15
18
  required — hide the SKIP affordances (default false, which keeps today's
16
19
  skippable card). The × then merely CLOSES and is labelled
17
20
  Close, and the "Skip for now" button is not rendered. For a
@@ -24,6 +27,11 @@
24
27
  follows `required`, because a card that cannot be skipped is
25
28
  talking to a different audience — a host that passes its own
26
29
  subtext still wins)
30
+ empty_error — the inline error shown when the field is submitted EMPTY. Like
31
+ subtext, its default follows `required`: a card that renders no
32
+ skip must not answer an empty field by offering one. Rendered
33
+ INSIDE the x-data, so it is escaped for a JS single-quoted
34
+ string on the way in — see CRITICAL below.
27
35
  placeholder — (default "Alex")
28
36
  placeholder_names — OPTIONAL, and OFF unless passed: an array of first names,
29
37
  one of which is TYPED into the placeholder a character at a
@@ -43,9 +51,13 @@
43
51
  than store a name nobody typed)
44
52
  progress — [current, total] to render the segmented pill, or nil for none
45
53
  modal_store — Alpine store name (default "modals"; the living style guide
46
- mounts its own page-scoped host and passes "dsModals")
54
+ mounts its own page-scoped host and passes "dsModals"). It lands
55
+ in IDENTIFIER position, not string position, so it is VALIDATED
56
+ rather than escaped and a value that is not a JS identifier
57
+ raises ArgumentError at render — see THE TWO SHAPES below.
47
58
  done_event — window event dispatched when this step is finished, saved or
48
- skipped (default "onboarding-step-done"). Its detail carries
59
+ skipped (default "onboarding-step-done"). JS-escaped, same as
60
+ submit_path. Its detail carries
49
61
  `{ next: [...], saved: true|false }` — see THE OUTCOME below.
50
62
  The HOST decides what happens next; this partial never knows.
51
63
 
@@ -73,6 +85,38 @@
73
85
  DOUBLE-QUOTED attribute — a single " anywhere inside it (a code comment
74
86
  included) closes it early and the whole component mounts as a silent no-op that
75
87
  still renders markup. Keep every inner string SINGLE-quoted.
88
+
89
+ THE TWO SHAPES — read this before adding a local to the x-data. Five locals are
90
+ interpolated into that attribute and they do NOT take the same repair. Getting
91
+ this wrong does not raise; it mounts a card that renders every element and does
92
+ nothing, which is why it is written down here rather than left to be noticed.
93
+
94
+ STRING position — empty_error, submit_path, skip_path, done_event — each sits
95
+ inside a JS SINGLE-quoted literal. A bare apostrophe closes the literal, the
96
+ expression becomes a SyntaxError, and the card is that silent no-op. Repair:
97
+ escape_javascript, in the INTERPOLATED form. The wrapper is load-bearing:
98
+ escape_javascript(SafeBuffer) answers true to html_safe, so ERB would skip
99
+ its own attribute-escaping half and a raw double quote could still close the
100
+ attribute. Wrapping the value in a plain interpolation first strips the safe
101
+ marking, so both escapers run.
102
+
103
+ IDENTIFIER position — modal_store — is spliced in as a bare NAME, at three
104
+ sites: the props getter, finish's close, and the Ruby-built dismiss_action
105
+ emitted into the x's click handler. Escaping is the WRONG repair here: an
106
+ escaped identifier is a different SyntaxError and the same dead card.
107
+ Repair: refuse a value that is not a JS identifier, which the Ruby block
108
+ below does once, at the source, so all three sites are covered.
109
+
110
+ A PATTERN, NOT AN ALLOWLIST, and the reason is worth keeping: an allowlist would
111
+ be this engine enumerating its own consumers, so the next app to mount a
112
+ page-scoped host would be refused by its own dependency until a gem release
113
+ admitted the name. The contract is narrower and stateless — it is spliced into
114
+ member-access position, so it must be an identifier, and which one is the host's
115
+ business.
116
+
117
+ NOTE THE SAME LOCAL TAKES THE OTHER REPAIR ELSEWHERE. blocks/_birthday and
118
+ blocks/_leveling_activity pass modal_store in STRING position and escape it,
119
+ correctly. The shape decides the repair, never the name of the local.
76
120
  %>
77
121
  <%
78
122
  submit_path = local_assigns.fetch(:submit_path, "/onboarding/first_name")
@@ -85,6 +129,31 @@
85
129
  "Just your first name — we use it to address you in emails."
86
130
  end
87
131
  subtext = local_assigns.fetch(:subtext, default_subtext)
132
+ # The inline error for an EMPTY field, resolved here for the same reason
133
+ # default_subtext is: `required` is known at render time, and a card that
134
+ # renders no skip button must not answer an empty field by offering one. It was
135
+ # a hard-coded literal until now, which is why a gated card told the user to
136
+ # skip for now and pointed at a button that is not on the page.
137
+ default_empty_error = if required
138
+ "Enter your first name to continue."
139
+ else
140
+ "Enter your first name, or skip for now."
141
+ end
142
+ empty_error = local_assigns.fetch(:empty_error, default_empty_error)
143
+ # ESCAPED FOR THE x-data, which is the hazard this local introduces: it is the
144
+ # first HOST-SUPPLIED PROSE to go inside that attribute, and prose has
145
+ # apostrophes. Interpolated raw, an error reading "We'll need a name" would
146
+ # close the JS single-quoted string, make the whole expression a SyntaxError,
147
+ # and mount the component as a SILENT NO-OP that still renders every element
148
+ # below. escape_javascript covers the apostrophe, the double quote, the
149
+ # backslash and the newline; the interpolation around it first strips any
150
+ # html_safe marking, because a safe string would skip ERB's own attribute
151
+ # escaping and could smuggle a raw double quote in — the exact failure the
152
+ # CRITICAL note above describes.
153
+ #
154
+ # INERT ON BOTH DEFAULTS. Neither default string contains a character either
155
+ # escaper touches, so the shipped card is byte-for-byte what it was.
156
+ empty_error_js = escape_javascript("#{empty_error}")
88
157
  placeholder = local_assigns.fetch(:placeholder, "Alex")
89
158
  # OFF unless a host passes a non-empty array. Normalised to nil so that an
90
159
  # empty list behaves exactly like an absent local rather than emitting the
@@ -95,7 +164,58 @@
95
164
  max_length = local_assigns.fetch(:max_length, Studio::FULL_NAME_MAX_LENGTH)
96
165
  progress = local_assigns.fetch(:progress, nil)
97
166
  modal_store = local_assigns.fetch(:modal_store, "modals")
167
+ # VALIDATED, NOT ESCAPED — and that distinction is the whole point of this local.
168
+ # Every other value inside the x-data is a JS STRING and is repaired by escaping.
169
+ # This one is spliced in as a bare NAME (`$store.<name>.current()`), so the same
170
+ # repair breaks it: escape_javascript turns `a'b` into `a\'b`, and
171
+ # `$store.a\'b.current()` is not a rescued identifier, it is a different
172
+ # SyntaxError — the same silently dead card, reached a longer way.
173
+ #
174
+ # A PATTERN RATHER THAN AN ALLOWLIST, deliberately. An allowlist would be this
175
+ # engine enumerating its own CONSUMERS — "modals" and the style guide's
176
+ # "dsModals" today — so the next app to mount a page-scoped host would be refused
177
+ # by its own dependency until a gem release admitted the name. That is backwards
178
+ # for a shared primitive. The real contract is narrower and stateless: this is
179
+ # spliced into member-access position, so it must be a JS identifier, and WHICH
180
+ # identifier is none of the engine's business.
181
+ #
182
+ # ASCII IdentifierName, which is what member access after a dot accepts. It
183
+ # admits every store name a host would plausibly write and rejects every
184
+ # character that could leave identifier position — quote, apostrophe, backslash,
185
+ # dot, space, semicolon, angle bracket.
186
+ #
187
+ # IT RAISES RATHER THAN FALLING BACK TO THE DEFAULT. A silent fallback is the
188
+ # worse of the two repairs: the card would mount, look perfect, and talk to a
189
+ # store that is not the host's — which is the SILENT-brick failure class this
190
+ # guard exists to leave behind. A host sees this the first time it renders.
191
+ unless modal_store.to_s.match?(/\A[A-Za-z_$][A-Za-z0-9_$]*\z/)
192
+ raise ArgumentError,
193
+ "modal_store must be a JS identifier (it is spliced into $store.<name>); " \
194
+ "got #{modal_store.inspect}. Escaping it would not help — an escaped " \
195
+ "identifier is a different SyntaxError, and the card would mount dead."
196
+ end
98
197
  done_event = local_assigns.fetch(:done_event, "onboarding-step-done")
198
+ # THE SAME HAZARD empty_error_js CARRIES, on the three remaining locals that land
199
+ # inside a JS SINGLE-quoted literal in the x-data below. A bare apostrophe in any
200
+ # of them closes its literal, the whole expression becomes a SyntaxError, and
201
+ # Alpine mounts the component as a SILENT NO-OP that still renders every element
202
+ # — perfect-looking markup, dead card.
203
+ #
204
+ # THE INTERPOLATED FORM IS LOAD-BEARING, not a style choice:
205
+ # escape_javascript(SafeBuffer).html_safe? is TRUE, so ERB would skip its own
206
+ # attribute-escaping half and a raw double quote could still close the
207
+ # double-quoted x-data. Wrapping in "#{}" first strips the html_safe marking, so
208
+ # both escapers run: escape_javascript for the JS literal, ERB for the attribute.
209
+ #
210
+ # INERT ON EVERY DEFAULT. Two paths, an event name and a store name carry no
211
+ # character either escaper touches, so the shipped card is byte-for-byte what it
212
+ # was — the golden-fixture pin in the view test says so. This is latent cover: no
213
+ # host supplies these as prose today, but empty_error established that prose does
214
+ # belong in this attribute, and the next local to carry an apostrophe will look
215
+ # like an ordinary change to whoever writes it.
216
+ submit_path_js = escape_javascript("#{submit_path}")
217
+ skip_path_js = escape_javascript("#{skip_path}")
218
+ done_event_js = escape_javascript("#{done_event}")
99
219
  field_id = local_assigns.fetch(:id, "onboarding-first-name")
100
220
  # The × mirrors the skip affordance it sits beside: it SKIPS while the step is
101
221
  # skippable, and merely CLOSES once it is required. Resolved here rather than in
@@ -175,9 +295,9 @@
175
295
  async save() {
176
296
  if (this.submitting) return;
177
297
  var value = (this.firstName || '').trim();
178
- if (!value) { this.error = 'Enter your first name, or skip for now.'; return; }
298
+ if (!value) { this.error = '<%= empty_error_js %>'; return; }
179
299
  this.submitting = true; this.error = '';
180
- var data = await this.post('<%= submit_path %>', { first_name: value });
300
+ var data = await this.post('<%= submit_path_js %>', { first_name: value });
181
301
  this.submitting = false;
182
302
  if (!data || !data.ok) {
183
303
  this.error = (data && data.error) || 'Could not save that — try again.';
@@ -188,7 +308,7 @@
188
308
  async skip() {
189
309
  if (this.submitting) return;
190
310
  this.submitting = true; this.error = '';
191
- var data = await this.post('<%= skip_path %>', {});
311
+ var data = await this.post('<%= skip_path_js %>', {});
192
312
  this.submitting = false;
193
313
  this.finish((data && data.next) || [], false);
194
314
  },
@@ -211,7 +331,7 @@
211
331
  <% if typed_placeholder %>
212
332
  this.stopPlaceholder();
213
333
  <% end %>
214
- window.dispatchEvent(new CustomEvent('<%= done_event %>', { detail: { next: next, saved: !!saved } }));
334
+ window.dispatchEvent(new CustomEvent('<%= done_event_js %>', { detail: { next: next, saved: !!saved } }));
215
335
  $store.<%= modal_store %>.close();
216
336
  }
217
337
  }"
@@ -1291,9 +1291,22 @@
1291
1291
  on a 20px row, which is not a difference anyone can see at thumbnail
1292
1292
  size — and these two cards are the pair most likely to be mistaken for
1293
1293
  duplicates, because they share a modal id AND a title. So the sketch
1294
- now shows the actual distinction: this card KNOWS which wallet. %>
1295
- <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
1296
- <div class="text-2xl leading-none">🔐</div>
1294
+ now shows the actual distinction: this card KNOWS which wallet.
1295
+
1296
+ THE HEADER IS THE WALLET'S OWN MARK, not a padlock. This crowned a
1297
+ padlock until 2026-09-07, which the card had already stopped drawing —
1298
+ a thumbnail showing a glyph the card never paints is the style guide
1299
+ misreporting the design system. The card now heads itself with the
1300
+ remembered wallet's brand mark, so the sketch heads itself with a
1301
+ brand tile, the same idiom the Connect-wallet thumbnail above uses.
1302
+
1303
+ A REAL SPRITE CANNOT BE USED HERE, and it is worth saying why so the
1304
+ next reader does not try. Every se-wallet symbol on this page is
1305
+ defined inside a template x-if, whose content is inert until Alpine
1306
+ clones it, so a use outside one resolves to nothing and paints an
1307
+ empty box. Thumbnails are sketches for that reason, not only taste. %>
1308
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2" data-test="step-up-thumb-brand">
1309
+ <span class="block w-7 h-7 mx-auto rounded-lg" style="background: var(--color-primary)"></span>
1297
1310
  <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
1298
1311
  <span class="flex items-center gap-1.5 h-7 w-full rounded-lg border px-1.5" style="border-color: var(--color-border-strong)">
1299
1312
  <span class="w-4 h-4 rounded shrink-0" style="background: var(--color-primary)"></span>
@@ -1311,9 +1324,21 @@
1311
1324
  disabled: web3_card_off, openable: web3_gem } do %>
1312
1325
  <%# An EMPTY slot: dashed outline, no brand tile, a "?" where the wallet's
1313
1326
  name would be. Reads at a glance as "we do not know which wallet",
1314
- which is the whole difference between this card and the one before it. %>
1315
- <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
1316
- <div class="text-2xl leading-none">🔐</div>
1327
+ which is the whole difference between this card and the one before it.
1328
+
1329
+ THE HEADER IS THE NEUTRAL BILLFOLD the card falls back to when no
1330
+ brand is remembered — the same rounded, inset-filled square drawn with
1331
+ the card's own billfold outline. It replaced a padlock on 2026-09-07,
1332
+ for the reason given on the card above this one. Drawing the billfold
1333
+ rather than tinting a tile keeps the two headers as different as the
1334
+ two cards are: one names a wallet, this one cannot. %>
1335
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2" data-test="step-up-thumb-no-brand">
1336
+ <span class="inline-flex w-7 h-7 rounded-lg bg-inset items-center justify-center" aria-hidden="true">
1337
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" class="w-4 h-4 text-secondary">
1338
+ <rect x="3" y="6.5" width="18" height="11" rx="2.5" />
1339
+ <circle cx="16.6" cy="12" r="1.3" fill="currentColor" stroke="none" />
1340
+ </svg>
1341
+ </span>
1317
1342
  <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
1318
1343
  <span class="flex items-center justify-center h-7 w-full rounded-lg border border-dashed" style="border-color: var(--color-border-strong)">
1319
1344
  <span class="text-xs font-bold text-muted leading-none">?</span>
@@ -32,6 +32,13 @@ module Studio
32
32
  primary = colors[:primary] || "#8E82FE"
33
33
  border_rgb = ColorScale.lighten(dark_base, 0.30)
34
34
  danger = colors[:danger] || "#EF4444"
35
+ surfaces = dark_surfaces(dark_base)
36
+
37
+ secondary_ink = contrast_ink(dark_base, direction: :lighten, start: 0.70, target: 4.5, against: surfaces)
38
+ muted_ink = ladder_clamp(
39
+ contrast_ink(dark_base, direction: :lighten, start: 0.55, target: 4.5, against: surfaces),
40
+ secondary_ink, direction: :lighten
41
+ )
35
42
 
36
43
  {
37
44
  "--color-page" => dark_base,
@@ -48,10 +55,15 @@ module Studio
48
55
  # until the ink clears its target on every emitted dark surface
49
56
  # (clamped at pure white for pathological bases). Note the blend
50
57
  # DESATURATES toward gray; only strongly-tinted bases keep a cast.
51
- "--color-text-secondary" => contrast_ink(dark_base, direction: :lighten, start: 0.70, target: 4.5,
52
- against: dark_surfaces(dark_base)),
53
- "--color-text-muted" => contrast_ink(dark_base, direction: :lighten, start: 0.55, target: 3.0,
54
- against: dark_surfaces(dark_base)),
58
+ "--color-text-secondary" => secondary_ink,
59
+ # MUTED IS NORMAL-SIZE TEXT, so its target is AA 4.5:1 — not 3.0.
60
+ # 3.0 is WCAG's LARGE-text allowance (>=18.66px, or 14px bold) and this
61
+ # ink does not land on large text: the engine's own `.label-upper`
62
+ # utility is `text-xs text-muted` (12px), and consumers render it at
63
+ # 11px. Measured on the default theme before this change: muted was
64
+ # #9896A4 at 3.84:1 on --color-surface (dark) and #818283 at 3.46:1 on
65
+ # --color-surface-alt (light) — both below AA, in BOTH themes.
66
+ "--color-text-muted" => muted_ink,
55
67
  "--color-border" => ColorScale.with_opacity(border_rgb, 0.2),
56
68
  "--color-border-strong" => ColorScale.with_opacity(border_rgb, 0.4),
57
69
  "--color-shadow" => "transparent",
@@ -95,6 +107,31 @@ module Studio
95
107
  ColorScale.darken(light_base, 0.08) ]
96
108
  end
97
109
 
110
+ # Keep the ink ladder monotonic: muted is the QUIETEST text ink and must
111
+ # never come out louder than secondary.
112
+ #
113
+ # This became reachable the moment muted's target rose to 4.5 and the two
114
+ # inks started sharing one threshold. They are found by the same stepped
115
+ # search from DIFFERENT starts (muted 0.40/0.55, secondary 0.55/0.70), so
116
+ # their grids are offset and the one that starts lower can overshoot PAST
117
+ # the one that starts higher. Measured on the default light base #f8fafc:
118
+ # the true minimum blend clearing 4.5 is 0.59, secondary lands exactly
119
+ # there, and muted — stepping 0.40, 0.42, ... — skips 0.59 and lands on
120
+ # 0.60, i.e. DARKER than secondary. The ladder inverted while every
121
+ # contrast assertion stayed green, because nothing compared the two.
122
+ #
123
+ # Ordering is a design decision, so make it structurally rather than let a
124
+ # 0.02 grid decide it. `direction` says which way "louder" runs: lightened
125
+ # ink on a dark base is louder as luminance RISES; darkened ink on a light
126
+ # base is louder as luminance FALLS.
127
+ def ladder_clamp(muted, secondary, direction:)
128
+ muted_l = ColorScale.relative_luminance(muted)
129
+ secondary_l = ColorScale.relative_luminance(secondary)
130
+ louder = direction == :lighten ? muted_l > secondary_l : muted_l < secondary_l
131
+
132
+ louder ? secondary : muted
133
+ end
134
+
98
135
  # Bounded, clamped search: raise the blend amount from `start` until the
99
136
  # ink clears `target` contrast against every background in `against`.
100
137
  # Clamps at 1.0 (pure white/black), so a pathological base degrades to the
@@ -133,6 +170,13 @@ module Studio
133
170
  light_base = colors[:light] || "#f8fafc"
134
171
  primary = colors[:primary] || "#8E82FE"
135
172
  danger = colors[:danger] || "#EF4444"
173
+ surfaces = light_surfaces(light_base)
174
+
175
+ secondary_ink = contrast_ink(light_base, direction: :darken, start: 0.55, target: 4.5, against: surfaces)
176
+ muted_ink = ladder_clamp(
177
+ contrast_ink(light_base, direction: :darken, start: 0.40, target: 4.5, against: surfaces),
178
+ secondary_ink, direction: :darken
179
+ )
136
180
 
137
181
  {
138
182
  "--color-page" => light_base,
@@ -144,10 +188,9 @@ module Studio
144
188
  # Same bounded search as dark mode: the old fixed grays measured as
145
189
  # low as 2.05:1 (muted on --color-inset) — below the very defect this
146
190
  # derivation exists to prevent. Ink darkens away from the light base.
147
- "--color-text-secondary" => contrast_ink(light_base, direction: :darken, start: 0.55, target: 4.5,
148
- against: light_surfaces(light_base)),
149
- "--color-text-muted" => contrast_ink(light_base, direction: :darken, start: 0.40, target: 3.0,
150
- against: light_surfaces(light_base)),
191
+ "--color-text-secondary" => secondary_ink,
192
+ # See the dark-mode note: muted is normal-size text and owes AA 4.5:1.
193
+ "--color-text-muted" => muted_ink,
151
194
  "--color-border" => ColorScale.darken(light_base, 0.08),
152
195
  "--color-border-strong" => ColorScale.darken(light_base, 0.15),
153
196
  "--color-shadow" => "rgba(0,0,0,0.05)",
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.72.1"
2
+ VERSION = "0.72.3"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.72.1
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-07 00:00:00.000000000 Z
11
+ date: 2026-09-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails