studio-engine 0.74.7 → 0.74.8

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.
data/CHANGELOG.md CHANGED
@@ -1,9 +1,42 @@
1
1
  # Changelog
2
2
 
3
- The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — `MAJOR.MINOR.PATCH`. Consumer Rails apps install the released RubyGems package and pin the floor each one needs — the pins differ on purpose, and every consumer records why beside its own. Bumping the gem version and updating consumer lockfiles is a release; `bin/release prepare` allocates the version and does both (see [`docs/RELEASE.md`](./docs/RELEASE.md)).
3
+ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — `MAJOR.MINOR.PATCH`. Consumer Rails apps install the released RubyGems package and pin the floor each one needs — the pins differ on purpose, and every consumer records why beside its own. Bumping the gem version and updating consumer lockfiles is a release; `bin/release prepare` allocates the version and does both (see [`docs/RELEASE.md`](./docs/RELEASE.md)). `prepare` does NOT roll this file: renaming `## Unreleased` to the allocated version and opening a fresh empty one is a manual conductor step (see [`docs/RELEASE.md`](./docs/RELEASE.md), *Rolling `Unreleased` into a version*), guarded by `test/docs/changelog_structure_test.rb`.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ### Docs
8
+
9
+ - **Thirty-five minor versions of shipped entries left `## Unreleased` and moved
10
+ under the version that actually shipped them.** The block spanned 2,382 lines and every
11
+ entry in it had already been published — `accepted` sat on the `v0.74.4` release
12
+ commit when they were rolled — so the heading said "pending" about the whole history of the gem, and
13
+ each reviewer who opened the file had to settle per entry which half it was in.
14
+ 85 entries plus three unbulleted prose blocks now sit under 46 version headings
15
+ from `0.40.0` to `0.74.3`; `## Unreleased` holds only this note.
16
+
17
+ **Bookkeeping only — no entry was reworded, added or dropped.** Attribution came
18
+ from git, never from position in the file: `git blame -w -M` on each entry, the
19
+ pickaxe (`git log -S`) for its introducing commit, and a scan of `CHANGELOG.md`
20
+ at all 81 release tags from `v0.39.0` up for the earliest tag whose file already
21
+ contained the entry's text. All three agreed on 81 of 85; each of the four splits
22
+ was a false `-M` match on boilerplate — a bare fenced-Ruby opener, a
23
+ `bin/rails … install:migrations` line — and resolved to the majority of five
24
+ per-entry probes. Verified as a permutation: 1,991 content lines before, the
25
+ same 1,991 after, none added, none lost.
26
+
27
+ **The cause, and the guard.** `bin/release prepare` commits `version.rb` with its
28
+ lockfile, publishes and tags — it never touches this file, and nothing failed
29
+ when the roll was skipped. `test/docs/changelog_structure_test.rb` now asserts the
30
+ file's SHAPE, never its prose: one leading `## Unreleased`, strictly decreasing
31
+ versions, no duplicate version, no orphan `###`, a parse floor so a rotted regex
32
+ fails loudly rather than passing vacuously, and — the one that would have caught
33
+ this in week one — `Studio::VERSION` no more than two minor versions ahead of the
34
+ newest heading. Against the pre-change file it reports "35 minor versions of
35
+ entries are still filed under `## Unreleased`". Automating the roll inside
36
+ `bin/release prepare` (mcritchie-studio) is the real fix and is not done.
37
+
38
+ ## 0.74.3 — 2026-09-08
39
+
7
40
  ### Fixed
8
41
 
9
42
  - **A store name that is not a JS identifier half-worked, silently, in seventeen
@@ -52,6 +85,10 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
52
85
  regex inline as the worked example and now calls the shared one — same pattern,
53
86
  same error, same message.
54
87
 
88
+ ## 0.74.2 — 2026-09-08
89
+
90
+ ### Fixed
91
+
55
92
  - **The email banner assembled two attributes INCLUDING THEIR OWN QUOTES in Ruby,
56
93
  so ERB escaping never ran on them.** `_layered_banner.html.erb` wrote
57
94
  `background="..."` and `bgcolor="..."` as Ruby strings and marked them
@@ -130,6 +167,10 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
130
167
  FOUR, so a false completeness claim was shipping one sentence above the entry
131
168
  announcing a fix for exactly that.
132
169
 
170
+ ## 0.74.1 — 2026-09-08
171
+
172
+ ### Fixed
173
+
133
174
  - **A quote in a host local can no longer close an Alpine attribute and turn the
134
175
  rest of the element into markup.** Four partials built an attribute — its OWN
135
176
  QUOTES included — as a Ruby String and marked it `html_safe`, which is the one
@@ -242,6 +283,10 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
242
283
  already clean. Both claims are corrected, and the comment now points at the test
243
284
  rather than restating that the sweep is done.
244
285
 
286
+ ## 0.74.0 — 2026-09-08
287
+
288
+ ### Fixed
289
+
245
290
  - **The app census in these comments was short by the most important app.** Ten
246
291
  sites said this engine is mounted by SIX apps and that THREE of them bundle no
247
292
  `solana-studio`. Measured 2026-09-07 by the criterion that reproduces it — a
@@ -285,6 +330,164 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
285
330
 
286
331
  Comment and prose only. No behaviour changes.
287
332
 
333
+ - **A host's apostrophe no longer kills the card it was passed to — now across the
334
+ blocks, not just the first-name step.** The same defect
335
+ `onboarding/_first_name` fixed one release ago turns out to be an engine-wide
336
+ idiom: a host-supplied local interpolated into a JS string literal inside a
337
+ JS-evaluating attribute. Repaired at eleven more splices.
338
+
339
+ **WHY IT IS WORTH A CHANGELOG LINE WHEN NOTHING VISIBLE CHANGES.** The failure
340
+ mode is silent. A bare apostrophe closes the JS literal, the whole expression
341
+ becomes a SyntaxError, and Alpine mounts the component as a NO-OP that still
342
+ renders every element — a card that looks perfect and whose buttons do nothing.
343
+ There is no error on screen, nothing in the server log, and no markup assertion
344
+ that can see it. Every value in every consumer resolves to a source literal or a
345
+ frozen constant today, so this is latent cover rather than a live fix; it is
346
+ worth doing because the next local to carry prose will look like an ordinary
347
+ change to whoever writes it.
348
+
349
+ **WHAT MOVED.** `blocks/_success_card` (`cta_event` at both CTA branches,
350
+ `secondary_event`), `blocks/_error_card` (`cta_event`, `secondary_event`),
351
+ `blocks/_entry_confirmed` and `blocks/_solana_tx_link` (`cluster_param`),
352
+ `modals/_crop_photo` (`store`), `studio/emails/show` (the two upload filenames
353
+ and the success sentence), and `profiles/_birthday_fields` (the date value).
354
+ No default or in-repo value contains a character either escaper touches, so
355
+ every shipped card renders byte-for-byte what it did.
356
+
357
+ **THE MECHANISM NOW HAS ONE HOME AND ONE GUARD.** `Studio::JsLiteral.in_attribute`
358
+ replaces the four inline copies in `_first_name`. TWO escapers have to run — one
359
+ for the JS literal, one for the HTML attribute — and the second only runs on a
360
+ value ERB still believes is unsafe, which is why the value is interpolated before
361
+ it is escaped. That subtlety was re-derived at every call site and had no test
362
+ anywhere; deleting it used to leave the suite green.
363
+
364
+ **NOT A FIX FOR IDENTIFIER POSITION, deliberately.** A local spliced in as a bare
365
+ NAME — `$store.<name>.close()` — must be VALIDATED, never escaped, because
366
+ `escape_javascript` also escapes `$` and mangles a legal store name. That fleet
367
+ (about 36 splices across 19 partials) and a third class found alongside it —
368
+ Ruby-ASSEMBLED JS emitted into an attribute, some of it already `html_safe` — are
369
+ scoped OUT of this change and carry their own tickets. The SHAPE of the splice
370
+ decides the repair, never the name of the local.
371
+
372
+ - **`Studio::S3` no longer mistakes a Rails NAMESPACE for a Rails application.**
373
+ `environment` guarded on `defined?(Rails)` and then called `Rails.env`.
374
+ rails-html-sanitizer — a transitive dependency of `action_view`, which arrives
375
+ long before any application does — ships a namespace-only `module Rails` with no
376
+ singleton methods, so that guard reads true and the next call raises
377
+ `NoMethodError: undefined method 'env' for module Rails`. It now asks
378
+ `Rails.respond_to?(:env)`, which is the form `lib/studio.rb` now uses in FOUR
379
+ places: the three it already carried (lines 701, 707 and 742) plus the route
380
+ guard at line 859. **This entry originally called it "the straggler"; that was
381
+ wrong** — three more sites carried the bare form, line 859 among them, and all
382
+ three are swept in *The Rails guard sweep really is finished now* above, in this
383
+ same unreleased block. No shipped app can reach it — every host boots a
384
+ real application — but the engine's own pure-Ruby unit lane can, and it did:
385
+ adding one `require` for a file that needs `action_view` turned an untouched
386
+ `email_catalog_test` red with two errors about email uploads.
387
+
388
+ ## 0.72.3 — 2026-09-07
389
+
390
+ ### Changed
391
+
392
+ - **The pinned stack composes itself, and publishes in the frame the change
393
+ happened.** `data-pin` layers now also publish `--pin-stack-bottom` (the bottom
394
+ of the whole stack) and `--pin-<name>-top` (the bottom of everything above that
395
+ layer), and the publisher writes them from inside its ResizeObserver callback
396
+ rather than deferring to `requestAnimationFrame`.
397
+
398
+ **THE DEFECT.** A frame runs `rAF -> style/layout -> ResizeObserver -> paint`,
399
+ so a write made in the frame callback lands at the top of the NEXT frame. Every
400
+ frame in which a pinned layer appeared or disappeared therefore painted with the
401
+ previous frame's number. Measured in isolation by toggling a layer's `display`
402
+ and sampling what the changing frame paints: rAF-deferred wrong on 8/8 changing
403
+ frames, synchronous wrong on 0/8. On mcritchie-studio's production
404
+ `/deployments` it showed as the board's swim-lane headers slamming 99px and back
405
+ on a page nobody was touching — parked at a fixed scroll offset with document
406
+ height, row position and nav height all constant, the header's `top` went
407
+ 152 -> 53 -> 152 -> 53px in 240ms behind two Turbo broadcasts.
408
+
409
+ **AND CONSUMERS NO LONGER COMPOSE THE STACK.** `top: max(var(--pin-nav-bottom),
410
+ var(--pin-apps-bottom))` fails twice: it does not scale, because a fourth layer
411
+ means editing every consumer that ever wanted to sit under the stack; and it is
412
+ not sound, because a `max()` over two custom properties is only meaningful if
413
+ they were written in the same frame. `--pin-stack-bottom` is one value that
414
+ cannot disagree with itself. A layer that is ITSELF in the stack reads
415
+ `--pin-<name>-top` instead, which excludes its own edge — positioning off the
416
+ stack bottom would make it chase itself down the page. Stacking order is
417
+ document order, with `data-pin-order` overriding it for a layer whose DOM
418
+ position does not match where it sits on screen.
419
+
420
+ **THE REGISTRY IS DERIVED PER PUBLISH, never cached.** A Turbo Stream replaces a
421
+ pinned layer's node without a `turbo:load`, and a held reference then points at
422
+ the DETACHED predecessor, whose rect is all zeros — indistinguishable from a
423
+ layer that is legitimately hidden. Re-querying makes the stale reference
424
+ impossible rather than merely unlikely.
425
+
426
+ **THE COST, re-measured**, because the rule this reverses was itself justified by
427
+ a measurement (turf-monster, 6x CPU throttle, frames over 20ms 13/24 through the
428
+ collapse ramp vs 0/24 once the observer was coalesced). What was expensive there
429
+ was read-write thrash, not the observer: the publisher now takes one read pass
430
+ and one write pass per invocation, and in an RO callback layout is already clean,
431
+ so those reads force nothing. On the engine lab at the same 6x throttle, ~700
432
+ frames of ramp each way — `RO through rAF` median 8.3ms / p90 10.0ms / 0 frames
433
+ over 20ms; `RO synchronous` median 8.4-8.5ms / p90 11.0ms / 3-6 frames over 20ms;
434
+ ZERO frames over 32ms on either, so neither drops one.
435
+
436
+ **BACK-COMPAT.** `--nav-h`, `--nav-bottom` and `--pin-<name>-h` /
437
+ `--pin-<name>-bottom` publish exactly as before, from the same measurements, so
438
+ the 26 consumers across turf-monster, rolio and mcritchie-studio need no change
439
+ and no floor bump. A structural change (a Turbo patch, a fresh document, a layer
440
+ joining or leaving) now forces a full write, because the unchanged-write skip is
441
+ only safe while nothing else touches these properties — the lab caught
442
+ `--nav-h` staying empty through a re-scan on exactly the host-owned-header path
443
+ both live consumers use. The lane contract moves 131 -> 132
444
+ (`config/e2e_lane.yml`), re-derived with the lister.
445
+
446
+ ## 0.72.2 — 2026-09-07
447
+
448
+ ### Fixed
449
+
450
+ - **The style guide's two "Sign Wallet" thumbnails no longer crown themselves
451
+ with a padlock the card stopped drawing, and the guide's lock no longer trails
452
+ the release that removed it.** solana-studio 0.6.1 replaced the step-up card's
453
+ padlock with the remembered wallet's own brand mark, falling back to a neutral
454
+ billfold where no brand is remembered. Both hand-drawn thumbnails in
455
+ `style/_modals` went on drawing the padlock, so the first thing a designer
456
+ reads advertised a glyph the design system had retired.
457
+
458
+ **THE THUMBNAILS WERE HALF OF IT.** The opened specimen renders the REAL
459
+ shared partial, but it does so through whatever `Gemfile.lock` resolves — so
460
+ it is only ever as current as the lock, and the lock sat on 0.6.0 while
461
+ turf-monster shipped 0.6.1. The guide's opened card was still drawing the
462
+ padlock too, which `bin/gem-drift-check` had already been failing on
463
+ (`engine 0.6.0 TRAILS turf-monster 0.6.1`). Correcting the sketches alone
464
+ would have put a brand mark on the thumbnail and a padlock in the card it
465
+ opens — moving the contradiction onto one screen rather than removing it. So
466
+ the lock had to move too, and it has: `accepted` carried the engine to
467
+ solana-studio 0.7.0 while this change sat in review, which is past the 0.6.1
468
+ that removed the padlock. This change no longer moves the lock itself — it
469
+ defers to the line `accepted` already holds — so the sketch and the card it
470
+ opens agree without it.
471
+
472
+ The sketches follow the card: a centered brand tile where a wallet is
473
+ remembered, the card's own billfold outline in an inset square where none is.
474
+ They stay SKETCHES rather than real brand marks for a mechanical reason worth
475
+ recording — every `se-wallet` symbol on the guide is defined inside a
476
+ `template x-if`, whose content is inert until Alpine clones it, so a `use`
477
+ placed outside one resolves to nothing and paints an empty box.
478
+
479
+ Guarded by `test/views/step_up_specimen_thumbnails_test.rb`, which slices each
480
+ thumbnail by a `data-test` hook before asserting. That slicing is the point:
481
+ the rendered guide already carried 17 `se-wallet-` hits from the picker and
482
+ the card's own CTA, so a page-wide assertion for the mark could not fail, and
483
+ a page-wide assertion against the padlock could not pass — the drag-board
484
+ specimen's prose legitimately contains one. The padlock is pinned in BOTH the
485
+ codepoint and the HTML-entity form. MEASURED: five mutants, five killed —
486
+ the padlock restored in either form, either header mark removed, and the lock
487
+ reverted to 0.6.0 each reddens the assertion that owns it.
488
+
489
+ ## 0.72.0 — 2026-09-07
490
+
288
491
  ### Added
289
492
 
290
493
  - **The first-name card now says WHICH path finished it, and can type its
@@ -344,6 +547,10 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
344
547
  test, while forcing the typed mode on, un-guarding the refocus, and treating
345
548
  an empty pool as present each hit a placeholder test.
346
549
 
550
+ ## 0.71.0 — 2026-09-07
551
+
552
+ ### Added
553
+
347
554
  - **The living style guide grows a HOST SECTION seam.** `/admin/style` rendered
348
555
  four hard-coded engine sections (Theme, Modals, Tricks, Tasks) with no way for
349
556
  a consuming app to contribute one of its own. An app now defines
@@ -377,6 +584,11 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
377
584
 
378
585
  **The seam ships no host section itself.** The first consumer is
379
586
  turf-monster's own `style/host/_modals`, which lands as its own task.
587
+
588
+ ## 0.70.0 — 2026-09-06
589
+
590
+ ### Added
591
+
380
592
  - **A `required` mode on the shared first-name card.**
381
593
  `studio/modals/onboarding/_first_name` grows one local. `required: true`
382
594
  renders the card with BOTH skip affordances gone — the "Skip for now" button
@@ -414,21 +626,6 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
414
626
  runtime — which is how the two cards already differ in copy, not just in
415
627
  affordances.
416
628
 
417
- - **Knowledge coverage view** — `/admin/knowledge/coverage` +
418
- `Studio::KnowledgeExpectation`: the "what SHOULD exist" half of the
419
- knowledge layer. Expectations carry an entity, folder, provenance
420
- (`source_note`, e.g. "diligence tracker item 14"), and a cadence — `once`
421
- (an LOI) or `monthly` (aging inventory), where each calendar month from
422
- `start_on` is a slot and the view names missing months outright
423
- (`2026-09 MISSING`). Documents fulfill expectations by an EXPLICIT
424
- `expectation_id` set at triage (the show page grows a "fulfills expectation"
425
- select; a fuzzy name-match would silently merge lookalikes, an id never
426
- does), and superseded documents never fill a slot. Ships two reference
427
- migrations (`create_studio_knowledge_expectations`,
428
- `add_expectation_to_studio_knowledge_docs`); routes ride the existing
429
- `Studio.draw_knowledge_routes` opt-in. Built for the Commercial Welding
430
- 65-item diligence tracker; app-agnostic like the rest of the layer.
431
-
432
629
  - **The style guide grows the two modal simulators that review ENGINE
433
630
  behaviour** — `#modals-motion-registry` ("Enter & leave animations") and
434
631
  `#modals-stack-mechanics` ("Stack behaviour") in `style/_modals`. Both are
@@ -464,212 +661,35 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
464
661
  reintroduced, with the lab server restarted between runs. The lane contract
465
662
  moves 116 → 123 (`config/e2e_lane.yml`), re-derived with the lister.
466
663
 
467
- ### Changed
468
-
469
- - **The pinned stack composes itself, and publishes in the frame the change
470
- happened.** `data-pin` layers now also publish `--pin-stack-bottom` (the bottom
471
- of the whole stack) and `--pin-<name>-top` (the bottom of everything above that
472
- layer), and the publisher writes them from inside its ResizeObserver callback
473
- rather than deferring to `requestAnimationFrame`.
664
+ ### Fixed
474
665
 
475
- **THE DEFECT.** A frame runs `rAF -> style/layout -> ResizeObserver -> paint`,
476
- so a write made in the frame callback lands at the top of the NEXT frame. Every
477
- frame in which a pinned layer appeared or disappeared therefore painted with the
478
- previous frame's number. Measured in isolation by toggling a layer's `display`
479
- and sampling what the changing frame paints: rAF-deferred wrong on 8/8 changing
480
- frames, synchronous wrong on 0/8. On mcritchie-studio's production
481
- `/deployments` it showed as the board's swim-lane headers slamming 99px and back
482
- on a page nobody was touching parked at a fixed scroll offset with document
483
- height, row position and nav height all constant, the header's `top` went
484
- 152 -> 53 -> 152 -> 53px in 240ms behind two Turbo broadcasts.
666
+ - **The style guide's page-scoped modal store now resolves animations through
667
+ the LIVE registry, so the guide can no longer disagree with itself.**
668
+ `style/_modals`' `dsModals` carried a hard-coded COPY of the animation table
669
+ while the new simulator builds its controls from `window.ModalAnimations`.
670
+ The two agreed today, which is exactly why this was invisible — but a
671
+ consumer registering a key would have grown a control from the registry that
672
+ the store then resolved back to `pop`: the button said "shake", the card
673
+ popped, and nothing reported the gap. Its `modalAnim` now reads
674
+ `window.ModalAnimations` at CALL time with the same late-binding guard the
675
+ shared host uses (unknown keys and a gutted registry still fall back to
676
+ `pop`, so a miss can never throw on `.ms` and strand a modal open); the local
677
+ table remains only as the fallback for a guide rendered without the host.
678
+ MEASURED in a browser both ways: registering a new key at runtime surfaces a
679
+ control AND plays it, and with this fix reverted the same key surfaces a
680
+ control that plays `modal-card-mount`.
485
681
 
486
- **AND CONSUMERS NO LONGER COMPOSE THE STACK.** `top: max(var(--pin-nav-bottom),
487
- var(--pin-apps-bottom))` fails twice: it does not scale, because a fourth layer
488
- means editing every consumer that ever wanted to sit under the stack; and it is
489
- not sound, because a `max()` over two custom properties is only meaningful if
490
- they were written in the same frame. `--pin-stack-bottom` is one value that
491
- cannot disagree with itself. A layer that is ITSELF in the stack reads
492
- `--pin-<name>-top` instead, which excludes its own edge — positioning off the
493
- stack bottom would make it chase itself down the page. Stacking order is
494
- document order, with `data-pin-order` overriding it for a layer whose DOM
495
- position does not match where it sits on screen.
682
+ ## 0.69.5 2026-09-06
496
683
 
497
- **THE REGISTRY IS DERIVED PER PUBLISH, never cached.** A Turbo Stream replaces a
498
- pinned layer's node without a `turbo:load`, and a held reference then points at
499
- the DETACHED predecessor, whose rect is all zeros — indistinguishable from a
500
- layer that is legitimately hidden. Re-querying makes the stale reference
501
- impossible rather than merely unlikely.
684
+ ### Fixed
502
685
 
503
- **THE COST, re-measured**, because the rule this reverses was itself justified by
504
- a measurement (turf-monster, 6x CPU throttle, frames over 20ms 13/24 through the
505
- collapse ramp vs 0/24 once the observer was coalesced). What was expensive there
506
- was read-write thrash, not the observer: the publisher now takes one read pass
507
- and one write pass per invocation, and in an RO callback layout is already clean,
508
- so those reads force nothing. On the engine lab at the same 6x throttle, ~700
509
- frames of ramp each way — `RO through rAF` median 8.3ms / p90 10.0ms / 0 frames
510
- over 20ms; `RO synchronous` median 8.4-8.5ms / p90 11.0ms / 3-6 frames over 20ms;
511
- ZERO frames over 32ms on either, so neither drops one.
512
-
513
- **BACK-COMPAT.** `--nav-h`, `--nav-bottom` and `--pin-<name>-h` /
514
- `--pin-<name>-bottom` publish exactly as before, from the same measurements, so
515
- the 26 consumers across turf-monster, rolio and mcritchie-studio need no change
516
- and no floor bump. A structural change (a Turbo patch, a fresh document, a layer
517
- joining or leaving) now forces a full write, because the unchanged-write skip is
518
- only safe while nothing else touches these properties — the lab caught
519
- `--nav-h` staying empty through a re-scan on exactly the host-owned-header path
520
- both live consumers use. The lane contract moves 131 -> 132
521
- (`config/e2e_lane.yml`), re-derived with the lister.
522
-
523
-
524
- - **`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.
525
- - **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.
526
- - **`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.
527
-
528
- - **The auth modal's Solana button moved to solana-studio, behind a CREDENTIAL
529
- SLOT.** `style/modals/_auth` no longer draws the button; it renders whatever
530
- resolves at `solana_studio/auth/wallet_credential` and nothing when that path
531
- is empty, so bundling the gem IS the registration and a web2 app carries no
532
- wallet markup at all. This completes the two-template split begun in 0.67.0,
533
- which moved the four web3 MODALS to the gem and left the sign-in button as the
534
- last web3 markup in the base engine. The gem half has shipped since
535
- solana-studio **0.5.2** — until now this engine referenced that partial
536
- nowhere, so the gem shipped a button no host rendered.
537
- **Supersedes the stranded PR #245**, whose consumer lane was red only because
538
- it opened before solana-studio 0.5.2 published.
539
-
540
- **What did NOT change, deliberately:** `_methodDefaults.wallet` is still
541
- `Studio.auth_method?(:wallet) && Studio.feature?(:web3)`. The Ruby gate
542
- answers "is it implemented" (is the picker registered, does the credential
543
- partial resolve) and gates the RENDER; Alpine's `methodOn('wallet')` still
544
- answers "should it show" and gates VISIBILITY, which keeps the style guide's
545
- method toggles working. Folding policy into the Ruby gate would delete the
546
- button from the DOM on a web3-off app that bundles the gem, and the "or"
547
- divider — which reads `methodOn('wallet')` too — would then float above a
548
- button that is not there.
549
-
550
- Hosts need no change: the engine's auth modal is a style-guide specimen
551
- reached through `/admin/style`, and an app that bundles solana-studio 0.5.2 or
552
- later sees the same button in the same place.
553
-
554
- ### Fixed
555
-
556
- - **A host's apostrophe no longer kills the card it was passed to — now across the
557
- blocks, not just the first-name step.** The same defect
558
- `onboarding/_first_name` fixed one release ago turns out to be an engine-wide
559
- idiom: a host-supplied local interpolated into a JS string literal inside a
560
- JS-evaluating attribute. Repaired at eleven more splices.
561
-
562
- **WHY IT IS WORTH A CHANGELOG LINE WHEN NOTHING VISIBLE CHANGES.** The failure
563
- mode is silent. A bare apostrophe closes the JS literal, the whole expression
564
- becomes a SyntaxError, and Alpine mounts the component as a NO-OP that still
565
- renders every element — a card that looks perfect and whose buttons do nothing.
566
- There is no error on screen, nothing in the server log, and no markup assertion
567
- that can see it. Every value in every consumer resolves to a source literal or a
568
- frozen constant today, so this is latent cover rather than a live fix; it is
569
- worth doing because the next local to carry prose will look like an ordinary
570
- change to whoever writes it.
571
-
572
- **WHAT MOVED.** `blocks/_success_card` (`cta_event` at both CTA branches,
573
- `secondary_event`), `blocks/_error_card` (`cta_event`, `secondary_event`),
574
- `blocks/_entry_confirmed` and `blocks/_solana_tx_link` (`cluster_param`),
575
- `modals/_crop_photo` (`store`), `studio/emails/show` (the two upload filenames
576
- and the success sentence), and `profiles/_birthday_fields` (the date value).
577
- No default or in-repo value contains a character either escaper touches, so
578
- every shipped card renders byte-for-byte what it did.
579
-
580
- **THE MECHANISM NOW HAS ONE HOME AND ONE GUARD.** `Studio::JsLiteral.in_attribute`
581
- replaces the four inline copies in `_first_name`. TWO escapers have to run — one
582
- for the JS literal, one for the HTML attribute — and the second only runs on a
583
- value ERB still believes is unsafe, which is why the value is interpolated before
584
- it is escaped. That subtlety was re-derived at every call site and had no test
585
- anywhere; deleting it used to leave the suite green.
586
-
587
- **NOT A FIX FOR IDENTIFIER POSITION, deliberately.** A local spliced in as a bare
588
- NAME — `$store.<name>.close()` — must be VALIDATED, never escaped, because
589
- `escape_javascript` also escapes `$` and mangles a legal store name. That fleet
590
- (about 36 splices across 19 partials) and a third class found alongside it —
591
- Ruby-ASSEMBLED JS emitted into an attribute, some of it already `html_safe` — are
592
- scoped OUT of this change and carry their own tickets. The SHAPE of the splice
593
- decides the repair, never the name of the local.
594
-
595
- - **`Studio::S3` no longer mistakes a Rails NAMESPACE for a Rails application.**
596
- `environment` guarded on `defined?(Rails)` and then called `Rails.env`.
597
- rails-html-sanitizer — a transitive dependency of `action_view`, which arrives
598
- long before any application does — ships a namespace-only `module Rails` with no
599
- singleton methods, so that guard reads true and the next call raises
600
- `NoMethodError: undefined method 'env' for module Rails`. It now asks
601
- `Rails.respond_to?(:env)`, which is the form `lib/studio.rb` now uses in FOUR
602
- places: the three it already carried (lines 701, 707 and 742) plus the route
603
- guard at line 859. **This entry originally called it "the straggler"; that was
604
- wrong** — three more sites carried the bare form, line 859 among them, and all
605
- three are swept in *The Rails guard sweep really is finished now* above, in this
606
- same unreleased block. No shipped app can reach it — every host boots a
607
- real application — but the engine's own pure-Ruby unit lane can, and it did:
608
- adding one `require` for a file that needs `action_view` turned an untouched
609
- `email_catalog_test` red with two errors about email uploads.
610
-
611
- - **The style guide's two "Sign Wallet" thumbnails no longer crown themselves
612
- with a padlock the card stopped drawing, and the guide's lock no longer trails
613
- the release that removed it.** solana-studio 0.6.1 replaced the step-up card's
614
- padlock with the remembered wallet's own brand mark, falling back to a neutral
615
- billfold where no brand is remembered. Both hand-drawn thumbnails in
616
- `style/_modals` went on drawing the padlock, so the first thing a designer
617
- reads advertised a glyph the design system had retired.
618
-
619
- **THE THUMBNAILS WERE HALF OF IT.** The opened specimen renders the REAL
620
- shared partial, but it does so through whatever `Gemfile.lock` resolves — so
621
- it is only ever as current as the lock, and the lock sat on 0.6.0 while
622
- turf-monster shipped 0.6.1. The guide's opened card was still drawing the
623
- padlock too, which `bin/gem-drift-check` had already been failing on
624
- (`engine 0.6.0 TRAILS turf-monster 0.6.1`). Correcting the sketches alone
625
- would have put a brand mark on the thumbnail and a padlock in the card it
626
- opens — moving the contradiction onto one screen rather than removing it. So
627
- the lock had to move too, and it has: `accepted` carried the engine to
628
- solana-studio 0.7.0 while this change sat in review, which is past the 0.6.1
629
- that removed the padlock. This change no longer moves the lock itself — it
630
- defers to the line `accepted` already holds — so the sketch and the card it
631
- opens agree without it.
632
-
633
- The sketches follow the card: a centered brand tile where a wallet is
634
- remembered, the card's own billfold outline in an inset square where none is.
635
- They stay SKETCHES rather than real brand marks for a mechanical reason worth
636
- recording — every `se-wallet` symbol on the guide is defined inside a
637
- `template x-if`, whose content is inert until Alpine clones it, so a `use`
638
- placed outside one resolves to nothing and paints an empty box.
639
-
640
- Guarded by `test/views/step_up_specimen_thumbnails_test.rb`, which slices each
641
- thumbnail by a `data-test` hook before asserting. That slicing is the point:
642
- the rendered guide already carried 17 `se-wallet-` hits from the picker and
643
- the card's own CTA, so a page-wide assertion for the mark could not fail, and
644
- a page-wide assertion against the padlock could not pass — the drag-board
645
- specimen's prose legitimately contains one. The padlock is pinned in BOTH the
646
- codepoint and the HTML-entity form. MEASURED: five mutants, five killed —
647
- the padlock restored in either form, either header mark removed, and the lock
648
- reverted to 0.6.0 each reddens the assertion that owns it.
649
-
650
- - **The style guide's page-scoped modal store now resolves animations through
651
- the LIVE registry, so the guide can no longer disagree with itself.**
652
- `style/_modals`' `dsModals` carried a hard-coded COPY of the animation table
653
- while the new simulator builds its controls from `window.ModalAnimations`.
654
- The two agreed today, which is exactly why this was invisible — but a
655
- consumer registering a key would have grown a control from the registry that
656
- the store then resolved back to `pop`: the button said "shake", the card
657
- popped, and nothing reported the gap. Its `modalAnim` now reads
658
- `window.ModalAnimations` at CALL time with the same late-binding guard the
659
- shared host uses (unknown keys and a gutted registry still fall back to
660
- `pop`, so a miss can never throw on `.ms` and strand a modal open); the local
661
- table remains only as the fallback for a guide rendered without the host.
662
- MEASURED in a browser both ways: registering a new key at runtime surfaces a
663
- control AND plays it, and with this fix reverted the same key surfaces a
664
- control that plays `modal-card-mount`.
665
-
666
- - **Onboarding no longer truncates a surname to fit a first name's cap.**
667
- `Studio::OnboardingController#first_name` measured the WHOLE typed answer with
668
- `Studio::FIRST_NAME_MAX_LENGTH` — the PER-FIELD cap, 40. The field asks for a
669
- name and people type a full one, so "Bartholomew Fitzwilliam
670
- Montgomery-Smythe" (41 characters) stored 40 of them and handed the account
671
- back its own surname misspelled, **"Montgomery-Smyth"**, on every surface
672
- `name` feeds.
686
+ - **Onboarding no longer truncates a surname to fit a first name's cap.**
687
+ `Studio::OnboardingController#first_name` measured the WHOLE typed answer with
688
+ `Studio::FIRST_NAME_MAX_LENGTH` the PER-FIELD cap, 40. The field asks for a
689
+ name and people type a full one, so "Bartholomew Fitzwilliam
690
+ Montgomery-Smythe" (41 characters) stored 40 of them and handed the account
691
+ back its own surname misspelled, **"Montgomery-Smyth"**, on every surface
692
+ `name` feeds.
673
693
 
674
694
  **ONE CONSTANT WAS ANSWERING TWO QUESTIONS**, which is why neither call site
675
695
  looked wrong. `/profile` applies the same number PER FIELD (`normalized_name`,
@@ -714,6 +734,10 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
714
734
  a row whose halves disagree with its `name` because of a `/profile` edit must
715
735
  never be auto-repaired anyway.
716
736
 
737
+ ## 0.69.4 — 2026-09-05
738
+
739
+ ### Fixed
740
+
717
741
  - **Onboarding no longer stores a two-word answer as the whole first name.**
718
742
  `Studio::OnboardingController#first_name` writes with `update_columns`, which
719
743
  skips callbacks — so the host's `before_save :set_name_parts` never ran and
@@ -748,6 +772,69 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
748
772
  and a fossil repair belongs to the consuming app that owns its `users` table,
749
773
  not to the gem.
750
774
 
775
+ ## 0.69.1 — 2026-09-02
776
+
777
+ ### Changed
778
+
779
+ - **`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.
780
+
781
+ - **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.
782
+
783
+ - **`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.
784
+
785
+ ## 0.69.0 — 2026-09-02
786
+
787
+ ### Added
788
+
789
+ - **Knowledge coverage view** — `/admin/knowledge/coverage` +
790
+ `Studio::KnowledgeExpectation`: the "what SHOULD exist" half of the
791
+ knowledge layer. Expectations carry an entity, folder, provenance
792
+ (`source_note`, e.g. "diligence tracker item 14"), and a cadence — `once`
793
+ (an LOI) or `monthly` (aging inventory), where each calendar month from
794
+ `start_on` is a slot and the view names missing months outright
795
+ (`2026-09 MISSING`). Documents fulfill expectations by an EXPLICIT
796
+ `expectation_id` set at triage (the show page grows a "fulfills expectation"
797
+ select; a fuzzy name-match would silently merge lookalikes, an id never
798
+ does), and superseded documents never fill a slot. Ships two reference
799
+ migrations (`create_studio_knowledge_expectations`,
800
+ `add_expectation_to_studio_knowledge_docs`); routes ride the existing
801
+ `Studio.draw_knowledge_routes` opt-in. Built for the Commercial Welding
802
+ 65-item diligence tracker; app-agnostic like the rest of the layer.
803
+
804
+ ## 0.68.0 — 2026-09-01
805
+
806
+ ### Changed
807
+
808
+ - **The auth modal's Solana button moved to solana-studio, behind a CREDENTIAL
809
+ SLOT.** `style/modals/_auth` no longer draws the button; it renders whatever
810
+ resolves at `solana_studio/auth/wallet_credential` and nothing when that path
811
+ is empty, so bundling the gem IS the registration and a web2 app carries no
812
+ wallet markup at all. This completes the two-template split begun in 0.67.0,
813
+ which moved the four web3 MODALS to the gem and left the sign-in button as the
814
+ last web3 markup in the base engine. The gem half has shipped since
815
+ solana-studio **0.5.2** — until now this engine referenced that partial
816
+ nowhere, so the gem shipped a button no host rendered.
817
+ **Supersedes the stranded PR #245**, whose consumer lane was red only because
818
+ it opened before solana-studio 0.5.2 published.
819
+
820
+ **What did NOT change, deliberately:** `_methodDefaults.wallet` is still
821
+ `Studio.auth_method?(:wallet) && Studio.feature?(:web3)`. The Ruby gate
822
+ answers "is it implemented" (is the picker registered, does the credential
823
+ partial resolve) and gates the RENDER; Alpine's `methodOn('wallet')` still
824
+ answers "should it show" and gates VISIBILITY, which keeps the style guide's
825
+ method toggles working. Folding policy into the Ruby gate would delete the
826
+ button from the DOM on a web3-off app that bundles the gem, and the "or"
827
+ divider — which reads `methodOn('wallet')` too — would then float above a
828
+ button that is not there.
829
+
830
+ Hosts need no change: the engine's auth modal is a style-guide specimen
831
+ reached through `/admin/style`, and an app that bundles solana-studio 0.5.2 or
832
+ later sees the same button in the same place.
833
+
834
+ ## 0.67.2 — 2026-09-01
835
+
836
+ ### Fixed
837
+
751
838
  - **Knowledge layer hardening** — the five findings from the 0.67.0 reviews:
752
839
  upload keys carry a random suffix (same-named uploads in the same second no
753
840
  longer overwrite the first object before the unique index can refuse);
@@ -761,6 +848,8 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
761
848
  noted in the migration header (consumer copies are linted by the consumer's
762
849
  rubocop — measured on the Industries adoption ship).
763
850
 
851
+ ## 0.67.0 — 2026-09-01
852
+
764
853
  ### Added
765
854
 
766
855
  - **Knowledge layer primitive** — `Studio::KnowledgeDoc` + `/admin/knowledge`:
@@ -787,6 +876,62 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
787
876
  Deferred to a later cut, recorded on the task: coverage view against a
788
877
  diligence tracker, recurring-series tracking, per-folder access defaults.
789
878
 
879
+ ## 0.66.0 — 2026-08-31
880
+
881
+ ### Changed
882
+
883
+ - **The base template is web2: `:wallet` has left the `auth_methods` default.**
884
+ `Studio.auth_methods` defaulted to `%i[magic_link google wallet]` while
885
+ `Studio.features` defaulted to `[]`. Those two defaults disagree, and the
886
+ disagreement was load-bearing: the auth modal renders its wallet button from
887
+ `auth_method?(:wallet) && feature?(:web3)`, so a stock app rendered **no**
888
+ wallet button — while `Studio.routes` draws the Solana trio from
889
+ `auth_method?(:wallet)` alone, so the same stock app published
890
+ `GET /auth/solana/nonce`, `POST /auth/solana/verify` and
891
+ `GET /auth/phantom/callback`. All three `skip_before_action
892
+ :require_authentication`, and `#verify` lands in `User.from_solana_wallet`,
893
+ which `validate_user_contract!` never requires a host to implement. A brand-new
894
+ newsletter app inherited three public endpoints into an unvalidated contract.
895
+
896
+ studio-engine + McRitchie Studio is the base template for **every** app, web2
897
+ and web3 alike; solana-studio + Turf Monster is the web3 bolt-on. Most apps are
898
+ web2, so the default now says so: `%i[magic_link google]`. `README.md` and
899
+ `docs/NEW_APP_SETUP.md` have always printed that exact line as the new-app
900
+ configuration — the code simply disagreed with the docs it shipped.
901
+
902
+ **No current consumer changes behaviour**, because all three declare
903
+ `auth_methods` explicitly: McRitchie Studio (`magic_link google wallet`),
904
+ Turf Monster (`magic_link google wallet`, and `draw_auth_routes = false`
905
+ besides, so the engine draws neither group for it), mcritchie-industries
906
+ (`magic_link`). A wallet app opts in to both knobs — `auth_methods` including
907
+ `:wallet` **and** `features` including `:web3`, the second being what makes the
908
+ button appear.
909
+
910
+ - **The auth route gate is pinned, and the gate choice is written down.**
911
+ Nothing tested which auth routes `Studio.routes` draws, on an engine whose
912
+ route changes break consumers at **boot** rather than at test time.
913
+ `test/integration/auth_route_gating_test.rb` draws the real host route table
914
+ under each consumer's configuration and reads the drawn routes back, so a
915
+ comment naming `auth/solana/nonce` cannot satisfy it.
916
+
917
+ The gate stays `draw_auth_routes && auth_method?(:wallet)` — **not** `&&
918
+ feature?(:web3)`, even though the modal uses both. `auth_methods` says which
919
+ CREDENTIALS an app accepts and these three paths are the credential exchange
920
+ itself; `features` gates product surfaces. `phantom_callback` is the mobile
921
+ deep-link RETURN url, so an app that declared `:wallet` and forgot `:web3`
922
+ would dead-end the handshake inside the user's wallet app with no server-side
923
+ trace — a worse failure than an endpoint that draws while the UI hides its
924
+ button. That decision is now a test with its rationale attached, so adding the
925
+ feature check flips a red assertion instead of silently unpublishing a live
926
+ consumer's routes. `Studio.draw_auth_routes`' comment likewise now records that
927
+ it is the OUTER switch, not the only gate: each group carries its own
928
+ `auth_methods` sub-gate, which is how a web2 app keeps magic-link while drawing
929
+ no `/auth/solana/*`.
930
+
931
+ ## 0.65.3 — 2026-08-31
932
+
933
+ ### Added
934
+
790
935
  - **A green comment-leak scan used to mean "not looked at".**
791
936
  `test/views/erb_comment_leak_test.rb` guards the ERB comment form in
792
937
  `app/views/**/*.erb`. It never looked inside `<script>`, and that is where this
@@ -808,22 +953,81 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
808
953
  line in an HTML comment does not help — ERB runs inside those too. Describing the
809
954
  tag in words stays legal, exactly as it is for ERB comments.
810
955
 
811
- **Finding a comment is the hard half, and the first cut of this got it wrong.**
812
- Anchoring `<script>` blocks on raw source let an ERB comment's *prose mention* of
813
- a script tag open the block: `studio/_board_assets.html.erb` reported 19_512
814
- bytes of markup as JavaScript and never saw its real program on its own terms.
815
- Blocks are now anchored on a copy with ERB tags and HTML comments blanked out,
816
- length preserved so offsets and line numbers still land. Inside a block the
817
- walker tracks the states that can hide a comment opener — a string
818
- (`"https://…"` is not a comment), a template literal, a regex (`/\/\//`) — and a
819
- literal or comment that never closes is REPORTED rather than absorbed, because a
820
- walker that quietly lost its place is the same green as a clean file. The
821
- assertions check the input as well as the verdict: how many blocks, comments and
822
- bytes the scan actually reached, and that every block it opened matches a real
823
- closing tag.
956
+ **Finding a comment is the hard half, and the first cut of this got it wrong.**
957
+ Anchoring `<script>` blocks on raw source let an ERB comment's *prose mention* of
958
+ a script tag open the block: `studio/_board_assets.html.erb` reported 19_512
959
+ bytes of markup as JavaScript and never saw its real program on its own terms.
960
+ Blocks are now anchored on a copy with ERB tags and HTML comments blanked out,
961
+ length preserved so offsets and line numbers still land. Inside a block the
962
+ walker tracks the states that can hide a comment opener — a string
963
+ (`"https://…"` is not a comment), a template literal, a regex (`/\/\//`) — and a
964
+ literal or comment that never closes is REPORTED rather than absorbed, because a
965
+ walker that quietly lost its place is the same green as a clean file. The
966
+ assertions check the input as well as the verdict: how many blocks, comments and
967
+ bytes the scan actually reached, and that every block it opened matches a real
968
+ closing tag.
969
+
970
+ Zero findings across the engine at `origin/accepted`, so this lands green with no
971
+ allowlist.
972
+
973
+ ## 0.65.2 — 2026-08-28
974
+
975
+ ### Fixed
976
+
977
+ - **A toast with buttons could not be dismissed while a modal was open.** The
978
+ layer scale put `--z-banner` (500) above `--z-toast` (400), and `body.modal-open`
979
+ lifts the environment bar stack to that tier at `position: sticky; top: 0`.
980
+ `#toast-container` is fixed at top 0 with `1rem` of padding, so the two land on
981
+ the same pixels: measured in a browser, the bars own y0-47 while the toast's
982
+ Dismiss button runs y28-40 — an 8-14px overlap at every viewport tested.
983
+ `elementFromPoint` at that button returned the **banner**, and a real click left
984
+ the toast on the page. Because the toast manager gives any toast carrying
985
+ buttons `duration: 0`, that X was the toast's **only** exit: it was stuck for
986
+ the rest of the session.
987
+
988
+ **The tiers are reordered** — `--z-banner` 400, `--z-toast-blur` 499, `--z-toast`
989
+ 500 — and that is a semantic decision rather than a nudge around the geometry: a
990
+ toast is transient and demands interaction, a banner is persistent chrome that
991
+ will still be there afterwards. `--z-banner` still clears `--z-modal` (200), so
992
+ the property the lift exists for — DEV MODE and the email chip stay lit and
993
+ clickable over a modal — is unchanged and now has its own spec.
994
+
995
+ **One measured cost.** A banner button's tooltip is a descendant of the bar
996
+ stack, and the modal-open lift makes that stack a stacking context, so the
997
+ tooltip composites at `--z-banner` rather than at its own `--z-tooltip` (600).
998
+ At 1440x900 the tooltip and the toast card never meet; at 390x844 they overlap
999
+ by 260x45px, so a banner tooltip opened with both a modal and a toast up is
1000
+ drawn under the toast. Hover/focus-only, purely visual, and recorded beside the
1001
+ scale.
1002
+
1003
+ **Consumers that redefine these tokens in their own `:root` after the engine
1004
+ import still win**, as they always have, so an app carrying a local copy of the
1005
+ scale keeps the old order (and the bug) until it drops the copy.
1006
+
1007
+ Two literals moved with the tiers: `#toast-container` and `.toast-page-blur` in
1008
+ `layouts/studio/_flash` carry `var(--studio-toast-z, var(--z-toast, …))`
1009
+ fallbacks, and a fallback that disagreed with the scale would hand the bug to
1010
+ any app rendering the partial without the engine sheet.
1011
+
1012
+ **The existing test could not have caught this and still cannot on its own.**
1013
+ `layer_scale_contract_test` asserted `--z-toast > --z-modal`, which was true the
1014
+ entire time the toast was unusable — both tiers cleared the modal, which says
1015
+ nothing about which of *them* wins. It now names the banner, and pins the halo
1016
+ directly beneath its own toast so no tier can settle between them. The property
1017
+ is also asserted where the defect lives, in `e2e/toast_over_banner.spec.js`: a
1018
+ hit test and a real mouse click at the Dismiss button, at desktop and phone
1019
+ widths, at scroll-top and at an offset. And once more on the artifact a
1020
+ consumer is actually served — `test/integration/layer_scale_build_test.rb`
1021
+ runs the real Tailwind binary over the engine's entry point and reads the
1022
+ tiers out of the COMPILED bundle, where `@import` resolution, layer ordering
1023
+ and a shadowing `:root` are all in play and a source read sees none of them. One trap is recorded there — at 390px
1024
+ the point under that button is the banner's Email link, so on the broken build a
1025
+ click *navigated* and the toast count on the new page was zero; asserting the
1026
+ count alone passes over the bug.
1027
+
1028
+ ## 0.65.0 — 2026-08-28
824
1029
 
825
- Zero findings across the engine at `origin/accepted`, so this lands green with no
826
- allowlist.
1030
+ ### Added
827
1031
 
828
1032
  - **`data-pin` — the pinned stack publishes itself.** `--nav-h` / `--nav-bottom`
829
1033
  answer for one element. Everything else that pins has been re-deriving the
@@ -885,56 +1089,28 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
885
1089
  read them, and `nav_offset_contract_test` still pins both to their exact
886
1090
  sources. This is additive.
887
1091
 
1092
+ ## 0.63.0 — 2026-08-27
888
1093
 
889
- ### Changed
890
-
891
- - **The base template is web2: `:wallet` has left the `auth_methods` default.**
892
- `Studio.auth_methods` defaulted to `%i[magic_link google wallet]` while
893
- `Studio.features` defaulted to `[]`. Those two defaults disagree, and the
894
- disagreement was load-bearing: the auth modal renders its wallet button from
895
- `auth_method?(:wallet) && feature?(:web3)`, so a stock app rendered **no**
896
- wallet button — while `Studio.routes` draws the Solana trio from
897
- `auth_method?(:wallet)` alone, so the same stock app published
898
- `GET /auth/solana/nonce`, `POST /auth/solana/verify` and
899
- `GET /auth/phantom/callback`. All three `skip_before_action
900
- :require_authentication`, and `#verify` lands in `User.from_solana_wallet`,
901
- which `validate_user_contract!` never requires a host to implement. A brand-new
902
- newsletter app inherited three public endpoints into an unvalidated contract.
903
-
904
- studio-engine + McRitchie Studio is the base template for **every** app, web2
905
- and web3 alike; solana-studio + Turf Monster is the web3 bolt-on. Most apps are
906
- web2, so the default now says so: `%i[magic_link google]`. `README.md` and
907
- `docs/NEW_APP_SETUP.md` have always printed that exact line as the new-app
908
- configuration — the code simply disagreed with the docs it shipped.
1094
+ ### Added
909
1095
 
910
- **No current consumer changes behaviour**, because all three declare
911
- `auth_methods` explicitly: McRitchie Studio (`magic_link google wallet`),
912
- Turf Monster (`magic_link google wallet`, and `draw_auth_routes = false`
913
- besides, so the engine draws neither group for it), mcritchie-industries
914
- (`magic_link`). A wallet app opts in to both knobs `auth_methods` including
915
- `:wallet` **and** `features` including `:web3`, the second being what makes the
916
- button appear.
1096
+ - **`studio/modals/_wallet_connect` the Connect Wallet picker, engine-owned.**
1097
+ The reown-style wallet chooser existed three times before this — turf-monster's
1098
+ 226 lines, mcritchie-studio's 107 and the style guide's 176 — sharing no code.
1099
+ It is one partial now, and `style/modals/_wallet_connect` CONFIGURES it rather
1100
+ than porting it, so the specimen and production cannot drift again. Locals:
1101
+ `store` (default `"modals"`), `connect_fn` (default `"solanaConnectAndVerify"`),
1102
+ `title`, `extra_data` (extra x-data members as a brace-less JS fragment), plus a
1103
+ BLOCK for the pre-connect slot. App behaviour arrives as optional hook METHODS
1104
+ defined in `extra_data`, each called only if it exists: `onInit`, `canPick`,
1105
+ `verifyArgs`, `onConnected(result)`, `onDeepLink`, `onBack`. Carries the mobile
1106
+ Phantom single-row fix (one Phantom row in every state) and a `role="alert"` on
1107
+ the connect error that both app copies lacked.
917
1108
 
918
- - **The auth route gate is pinned, and the gate choice is written down.**
919
- Nothing tested which auth routes `Studio.routes` draws, on an engine whose
920
- route changes break consumers at **boot** rather than at test time.
921
- `test/integration/auth_route_gating_test.rb` draws the real host route table
922
- under each consumer's configuration and reads the drawn routes back, so a
923
- comment naming `auth/solana/nonce` cannot satisfy it.
1109
+ **NO SHIPPING CONSUMER RENDERS IT YET.** Both apps still ship their own copy and
1110
+ adopt this one after it releases, so the green consumer CI here says they still
1111
+ compile it says nothing about this partial.
924
1112
 
925
- The gate stays `draw_auth_routes && auth_method?(:wallet)` — **not** `&&
926
- feature?(:web3)`, even though the modal uses both. `auth_methods` says which
927
- CREDENTIALS an app accepts and these three paths are the credential exchange
928
- itself; `features` gates product surfaces. `phantom_callback` is the mobile
929
- deep-link RETURN url, so an app that declared `:wallet` and forgot `:web3`
930
- would dead-end the handshake inside the user's wallet app with no server-side
931
- trace — a worse failure than an endpoint that draws while the UI hides its
932
- button. That decision is now a test with its rationale attached, so adding the
933
- feature check flips a red assertion instead of silently unpublishing a live
934
- consumer's routes. `Studio.draw_auth_routes`' comment likewise now records that
935
- it is the OUTER switch, not the only gate: each group carries its own
936
- `auth_methods` sub-gate, which is how a web2 app keeps magic-link while drawing
937
- no `/auth/solana/*`.
1113
+ ### Changed
938
1114
 
939
1115
  - **The navbar collapse primitive is broadcastable, rate-limited, and cheap per
940
1116
  frame.** Three defects in the primitive as first landed, none of which a
@@ -985,60 +1161,103 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
985
1161
  so the source-swap mutation it was written for still dies, and it gained a
986
1162
  guard on the read-before-write order.
987
1163
 
1164
+ ## 0.62.6 — 2026-08-27
1165
+
1166
+ ### Added
1167
+
1168
+ - **`--nav-p` and `navCollapse()` — the navbar collapse becomes a primitive.**
1169
+ `layouts/studio/_head` now publishes a third header property beside `--nav-h`
1170
+ and `--nav-bottom`. The first two are measurements OF the header; `--nav-p` is
1171
+ an input TO it: collapse progress, `0` expanded to `1` collapsed, written on
1172
+ the `<header>` once per animation frame from `window.scrollY`, and registered
1173
+ with `@property` as a `<number>` so it is legal inside `calc()`, real before
1174
+ the first scroll, and interpolable.
1175
+
1176
+ **It ships no sizing opinion.** A host adopts it by putting `nav-shell` and
1177
+ `x-data="navCollapse()"` on its header, giving each breakpoint band a
1178
+ `--nav-ramp`, and writing its collapsing dimensions as `calc()`s off
1179
+ `--nav-p`. An app whose navbar collapses to different endpoints than this
1180
+ gem's — `mcritchie-studio` goes `w-8 → w-5`, not `w-12 → w-8` — can adopt the
1181
+ mechanism without changing its endpoints.
1182
+
1183
+ **NO SHIPPING CONSUMER EXERCISES THIS YET, and the adoptions are follow-ons.**
1184
+ `turf-monster` overrides `layouts/_navbar.html.erb` AND redefines
1185
+ `window.navCollapse` in `shared/_alpine_factories.html.erb`, rendered after the
1186
+ engine head, so the engine's copy is shadowed. `mcritchie-studio` never renders
1187
+ `layouts/navbar` at all — its inline header still carries `@scroll.window` and
1188
+ `transition-all duration-300`. Said plainly because it also means the green
1189
+ consumer CI on this change is **not evidence about this code path**: both apps
1190
+ compile and pass against a primitive neither of them runs.
988
1191
 
989
1192
  ### Fixed
990
1193
 
991
- - **A toast with buttons could not be dismissed while a modal was open.** The
992
- layer scale put `--z-banner` (500) above `--z-toast` (400), and `body.modal-open`
993
- lifts the environment bar stack to that tier at `position: sticky; top: 0`.
994
- `#toast-container` is fixed at top 0 with `1rem` of padding, so the two land on
995
- the same pixels: measured in a browser, the bars own y0-47 while the toast's
996
- Dismiss button runs y28-40 an 8-14px overlap at every viewport tested.
997
- `elementFromPoint` at that button returned the **banner**, and a real click left
998
- the toast on the page. Because the toast manager gives any toast carrying
999
- buttons `duration: 0`, that X was the toast's **only** exit: it was stuck for
1000
- the rest of the session.
1194
+ - **The engine navbar's collapse tracks the finger instead of a clock.**
1195
+ `layouts/_navbar` flipped `scrolled` at `scrollY > 60` and fed that step into
1196
+ `transition-all duration-300` on padding, logo `width`/`height` and two font
1197
+ sizes. The finger set the step; an ease curve owned everything after it.
1198
+ Measured in `turf-monster` at 390×844 on the same code before the change: the
1199
+ header ran 178px 139px and **34 of those 39px of document reflow landed
1200
+ AFTER the scroll had stopped, over 232ms**, at up to 3px per frame — plus a
1201
+ 1px REVERSE lurch in the frame the class flipped, where a discrete
1202
+ `text-3xl → text-xl` swap collided with the stylesheet's own
1203
+ `transition: font-size`.
1001
1204
 
1002
- **The tiers are reordered** `--z-banner` 400, `--z-toast-blur` 499, `--z-toast`
1003
- 500 and that is a semantic decision rather than a nudge around the geometry: a
1004
- toast is transient and demands interaction, a banner is persistent chrome that
1005
- will still be there afterwards. `--z-banner` still clears `--z-modal` (200), so
1006
- the property the lift exists for — DEV MODE and the email chip stay lit and
1007
- clickable over a modal — is unchanged and now has its own spec.
1205
+ Every collapsing dimension is now a `calc()` off `--nav-p`, and nothing on the
1206
+ path carries a time-based transition. **Both endpoints are unchanged at every
1207
+ breakpoint** — this is the same navbar, re-plumbed, so a consumer sees no
1208
+ visual difference at rest.
1008
1209
 
1009
- **One measured cost.** A banner button's tooltip is a descendant of the bar
1010
- stack, and the modal-open lift makes that stack a stacking context, so the
1011
- tooltip composites at `--z-banner` rather than at its own `--z-tooltip` (600).
1012
- At 1440x900 the tooltip and the toast card never meet; at 390x844 they overlap
1013
- by 260x45px, so a banner tooltip opened with both a modal and a toast up is
1014
- drawn under the toast. Hover/focus-only, purely visual, and recorded beside the
1015
- scale.
1210
+ Content still accelerates *during* the collapse: a sticky header is in flow,
1211
+ so shrinking it adds to the scroll and content necessarily outruns the finger.
1212
+ That is the point of a collapse, not a defect. What is tunable is the shape,
1213
+ and `--nav-ramp` is the band's collapse total with a smoothstep ease, whose
1214
+ slope is zero at both ends so the burst leaves 1×, peaks near 1.5×, and
1215
+ returns to with no velocity step. A linear ramp equal to the collapse hits
1216
+ 2× and steps straight back.
1016
1217
 
1017
- **Consumers that redefine these tokens in their own `:root` after the engine
1018
- import still win**, as they always have, so an app carrying a local copy of the
1019
- scale keeps the old order (and the bug) until it drops the copy.
1218
+ Also included: a passive, rAF-coalesced listener that writes to the header
1219
+ rather than `:root` (keeping each frame's style recalc inside the navbar
1220
+ subtree); a short-page guard, because collapsing shortens the document and on
1221
+ a barely-scrollable page that deletes the very scroll room that triggered it,
1222
+ clamps `scrollY` to 0, and flaps forever; and a `prefers-reduced-motion`
1223
+ branch that snaps `--nav-p` to `0`/`1`, since resizing type under a moving
1224
+ finger is itself motion worth dropping.
1020
1225
 
1021
- Two literals moved with the tiers: `#toast-container` and `.toast-page-blur` in
1022
- `layouts/studio/_flash` carry `var(--studio-toast-z, var(--z-toast, …))`
1023
- fallbacks, and a fallback that disagreed with the scale would hand the bug to
1024
- any app rendering the partial without the engine sheet.
1226
+ `/navbar`'s Scrolled toggle drops **twelve `!important` rules** that restated
1227
+ every collapsed value — it sets `--nav-p: 0|1` and transitions the registered
1228
+ property, so the preview exercises the shipped `calc()`s instead of a parallel
1229
+ copy of them.
1025
1230
 
1026
- **The existing test could not have caught this and still cannot on its own.**
1027
- `layer_scale_contract_test` asserted `--z-toast > --z-modal`, which was true the
1028
- entire time the toast was unusable — both tiers cleared the modal, which says
1029
- nothing about which of *them* wins. It now names the banner, and pins the halo
1030
- directly beneath its own toast so no tier can settle between them. The property
1031
- is also asserted where the defect lives, in `e2e/toast_over_banner.spec.js`: a
1032
- hit test and a real mouse click at the Dismiss button, at desktop and phone
1033
- widths, at scroll-top and at an offset. And once more on the artifact a
1034
- consumer is actually served `test/integration/layer_scale_build_test.rb`
1035
- runs the real Tailwind binary over the engine's entry point and reads the
1036
- tiers out of the COMPILED bundle, where `@import` resolution, layer ordering
1037
- and a shadowing `:root` are all in play and a source read sees none of them. One trap is recorded there — at 390px
1038
- the point under that button is the banner's Email link, so on the broken build a
1039
- click *navigated* and the toast count on the new page was zero; asserting the
1040
- count alone passes over the bug.
1231
+ ## 0.61.2 2026-08-26
1232
+
1233
+ ### Fixed
1234
+
1235
+ - **The age gate's back link now returns the date the person already entered.**
1236
+ `blocks/_age_gate`'s "Update your Birthday" swapped back to `blocks/_birthday`
1237
+ with an EMPTY props object, discarding the `dobYear`/`dobMonth`/`dobDay` parts
1238
+ the birthday factory had just handed across the store and the factory started
1239
+ its three fields at `""` and never read them anyway. So the correction path came
1240
+ back BLANK: a mistyped year cost all three picks, on a card whose own header
1241
+ comment promised "a correction, not a restart".
1242
+
1243
+ Both sides of the seam moved. `back()` forwards the three date parts and only
1244
+ those three, deliberately: `minAge`, `state` and `message` describe the refusal,
1245
+ and `validates` is load-bearing by its ABSENCE (the style guide specimen reads an
1246
+ absent prop as validating, and a forwarded stale one would let the refusal pick
1247
+ the next card's mode). `window.birthdayModal` gained an `init` that re-picks the
1248
+ three selects from the props of the modal entry it mounted in, clamping a day
1249
+ that the restored month cannot hold.
1250
+
1251
+ **The gate is not weakened.** Restoring the date restores the DATE and never the
1252
+ verdict: the card comes back submittable and the app's endpoint re-decides on the
1253
+ next submit exactly as it did on the first. `e2e/birthday_gate.spec.js` asserts a
1254
+ restored under-age date is refused again.
1255
+
1256
+ **No consumer action.** A host that renders `blocks/_age_gate` and
1257
+ `studio/_birthday_assets` gets this by upgrading; nothing in the call signature
1258
+ changed and no host wiring moves.
1041
1259
 
1260
+ ## 0.60.1 — 2026-08-25
1042
1261
 
1043
1262
  ### Breaking
1044
1263
 
@@ -1067,38 +1286,14 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
1067
1286
  that pass a number are unaffected. There is still NO engine default, because 18
1068
1287
  is itself a policy value.
1069
1288
 
1070
- - **An under-age date now SUBMITS.** It used to disable the card's own submit
1071
- button and turn the card red — the one screen state with nothing to press. The
1072
- factory routes on the RESPONSE instead: `body.underage === true`, or HTTP 403,
1073
- opens the age-gate card; anything else non-verified stays on the error line. An
1074
- app adopting this must make its verify endpoint answer "too young" distinctly
1075
- from "bad request", because a client that blocks submission never had to.
1076
-
1077
- ### Added
1078
-
1079
- - **`--nav-p` and `navCollapse()` — the navbar collapse becomes a primitive.**
1080
- `layouts/studio/_head` now publishes a third header property beside `--nav-h`
1081
- and `--nav-bottom`. The first two are measurements OF the header; `--nav-p` is
1082
- an input TO it: collapse progress, `0` expanded to `1` collapsed, written on
1083
- the `<header>` once per animation frame from `window.scrollY`, and registered
1084
- with `@property` as a `<number>` so it is legal inside `calc()`, real before
1085
- the first scroll, and interpolable.
1086
-
1087
- **It ships no sizing opinion.** A host adopts it by putting `nav-shell` and
1088
- `x-data="navCollapse()"` on its header, giving each breakpoint band a
1089
- `--nav-ramp`, and writing its collapsing dimensions as `calc()`s off
1090
- `--nav-p`. An app whose navbar collapses to different endpoints than this
1091
- gem's — `mcritchie-studio` goes `w-8 → w-5`, not `w-12 → w-8` — can adopt the
1092
- mechanism without changing its endpoints.
1093
-
1094
- **NO SHIPPING CONSUMER EXERCISES THIS YET, and the adoptions are follow-ons.**
1095
- `turf-monster` overrides `layouts/_navbar.html.erb` AND redefines
1096
- `window.navCollapse` in `shared/_alpine_factories.html.erb`, rendered after the
1097
- engine head, so the engine's copy is shadowed. `mcritchie-studio` never renders
1098
- `layouts/navbar` at all — its inline header still carries `@scroll.window` and
1099
- `transition-all duration-300`. Said plainly because it also means the green
1100
- consumer CI on this change is **not evidence about this code path**: both apps
1101
- compile and pass against a primitive neither of them runs.
1289
+ - **An under-age date now SUBMITS.** It used to disable the card's own submit
1290
+ button and turn the card red — the one screen state with nothing to press. The
1291
+ factory routes on the RESPONSE instead: `body.underage === true`, or HTTP 403,
1292
+ opens the age-gate card; anything else non-verified stays on the error line. An
1293
+ app adopting this must make its verify endpoint answer "too young" distinctly
1294
+ from "bad request", because a client that blocks submission never had to.
1295
+
1296
+ ### Added
1102
1297
 
1103
1298
  - **`studio/modals/blocks/_age_gate` — the refusal card.** Where the birthday card
1104
1299
  hands off when the date is under the app's bar. Headed "Easy, Young'un" under a
@@ -1109,61 +1304,54 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
1109
1304
  absent rather than rendered dead) and a back link that returns to the birthday
1110
1305
  card. Displays `min_age` / `state`; computes no eligibility of its own.
1111
1306
 
1112
- - **`studio/modals/_wallet_connect`the Connect Wallet picker, engine-owned.**
1113
- The reown-style wallet chooser existed three times before this — turf-monster's
1114
- 226 lines, mcritchie-studio's 107 and the style guide's 176 — sharing no code.
1115
- It is one partial now, and `style/modals/_wallet_connect` CONFIGURES it rather
1116
- than porting it, so the specimen and production cannot drift again. Locals:
1117
- `store` (default `"modals"`), `connect_fn` (default `"solanaConnectAndVerify"`),
1118
- `title`, `extra_data` (extra x-data members as a brace-less JS fragment), plus a
1119
- BLOCK for the pre-connect slot. App behaviour arrives as optional hook METHODS
1120
- defined in `extra_data`, each called only if it exists: `onInit`, `canPick`,
1121
- `verifyArgs`, `onConnected(result)`, `onDeepLink`, `onBack`. Carries the mobile
1122
- Phantom single-row fix (one Phantom row in every state) and a `role="alert"` on
1123
- the connect error that both app copies lacked.
1124
-
1125
- **NO SHIPPING CONSUMER RENDERS IT YET.** Both apps still ship their own copy and
1126
- adopt this one after it releases, so the green consumer CI here says they still
1127
- compile — it says nothing about this partial.
1307
+ ## 0.59.02026-08-21
1128
1308
 
1129
1309
  ### Fixed
1130
1310
 
1131
- - **The engine navbar's collapse tracks the finger instead of a clock.**
1132
- `layouts/_navbar` flipped `scrolled` at `scrollY > 60` and fed that step into
1133
- `transition-all duration-300` on padding, logo `width`/`height` and two font
1134
- sizes. The finger set the step; an ease curve owned everything after it.
1135
- Measured in `turf-monster` at 390×844 on the same code before the change: the
1136
- header ran 178px 139px and **34 of those 39px of document reflow landed
1137
- AFTER the scroll had stopped, over 232ms**, at up to 3px per frame — plus a
1138
- 1px REVERSE lurch in the frame the class flipped, where a discrete
1139
- `text-3xl text-xl` swap collided with the stylesheet's own
1140
- `transition: font-size`.
1311
+ - **The Geo signpost now reaches every app whose admin chrome the engine owns.**
1312
+ The row added last release went into `components/_admin_dropdown` alone, on the
1313
+ premise that the shared dropdown reaches every app from one change. It does
1314
+ not, and counted against the apps that actually consume this engine it reached
1315
+ **none of them**. Wherever a host declares an admin-flagged sidebar section,
1316
+ `studio_sidebar_replaces_admin_menu?` SUPPRESSES the dropdown and the link
1317
+ sidebar is the admin menu that is both `mcritchie-studio` and
1318
+ `mcritchie-industries`. An app that forks its navbar outright renders neither,
1319
+ which is `turf-monster`. Nobody was left on the plain engine navbar to see it,
1320
+ so the app carrying the largest admin surface in the ecosystem was among those
1321
+ guaranteed never to.
1141
1322
 
1142
- Every collapsing dimension is now a `calc()` off `--nav-p`, and nothing on the
1143
- path carries a time-based transition. **Both endpoints are unchanged at every
1144
- breakpoint** this is the same navbar, re-plumbed, so a consumer sees no
1145
- visual difference at rest.
1323
+ The row moved into its own partial, **`components/_geo_signpost`**, and BOTH
1324
+ engine chromes render it the dropdown as before, and
1325
+ `components/_link_sidebar` under its own admin-chipped `Geo` heading. Nothing
1326
+ to configure: a host on either chrome gets the signage from the version bump.
1146
1327
 
1147
- Content still accelerates *during* the collapse: a sticky header is in flow,
1148
- so shrinking it adds to the scroll and content necessarily outruns the finger.
1149
- That is the point of a collapse, not a defect. What is tunable is the shape,
1150
- and `--nav-ramp` is the band's collapse total with a smoothstep ease, whose
1151
- slope is zero at both ends so the burst leaves 1×, peaks near 1.5×, and
1152
- returns to 1× with no velocity step. A linear ramp equal to the collapse hits
1153
- 2× and steps straight back.
1328
+ **Exactly one of them, never both.** The sidebar carries the row only where it
1329
+ IS the admin menu (the host declared an admin-flagged section, so the dropdown
1330
+ is suppressed). A host declaring only public sections renders BOTH chromes by
1331
+ design, and there the dropdown keeps the row fixing a reach gap by showing
1332
+ an admin the same row twice is not a fix.
1154
1333
 
1155
- Also included: a passive, rAF-coalesced listener that writes to the header
1156
- rather than `:root` (keeping each frame's style recalc inside the navbar
1157
- subtree); a short-page guard, because collapsing shortens the document and on
1158
- a barely-scrollable page that deletes the very scroll room that triggered it,
1159
- clamps `scrollY` to 0, and flaps forever; and a `prefers-reduced-motion`
1160
- branch that snaps `--nav-p` to `0`/`1`, since resizing type under a moving
1161
- finger is itself motion worth dropping.
1334
+ **A forked chrome still needs one line**, because the engine cannot reach a
1335
+ fork by rendering. Inside your own admin block:
1162
1336
 
1163
- `/navbar`'s Scrolled toggle drops **twelve `!important` rules** that restated
1164
- every collapsed value — it sets `--nav-p: 0|1` and transitions the registered
1165
- property, so the preview exercises the shipped `calc()`s instead of a parallel
1166
- copy of them.
1337
+ ```erb
1338
+ <%= render "components/geo_signpost",
1339
+ variant: :sidebar, close_action: "$store.sidebars.gearOpen = false" %>
1340
+ ```
1341
+
1342
+ `variant:` picks the chrome's row shape (`:dropdown`, the default, or
1343
+ `:sidebar`); `close_action:` is the Alpine expression that closes YOUR panel
1344
+ behind the row — the store flag belongs to the caller, so the partial never
1345
+ hardcodes one. See [`docs/GEO.md`](docs/GEO.md#finding-the-page--the-signpost-in-whichever-chrome-the-app-has).
1346
+
1347
+ Behavior is otherwise unchanged: same three states, same words, same
1348
+ self-gating on `admin?`, and `ENABLE_GEO_BLOCKING` still governs the LINK and
1349
+ never the gate. One test hook was renamed — `data-test="geo-signpost-disabled"`,
1350
+ was `admin-dropdown-geo-disabled` — since the row renders in two chromes now
1351
+ and a name claiming one of them was a lie. Grepped ecosystem-wide first: it
1352
+ appeared in no consumer.
1353
+
1354
+ ## 0.58.0 — 2026-08-21
1167
1355
 
1168
1356
  ### Added
1169
1357
 
@@ -1181,6 +1369,10 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
1181
1369
  enforcement would silently stop a live legal blocklist on the next deploy. An
1182
1370
  app that would rather decide in code sets `config.geo_blocking_enabled`.
1183
1371
 
1372
+ ## 0.57.0 — 2026-08-20
1373
+
1374
+ ### Added
1375
+
1184
1376
  - **Geo — validation for every app, locking for the apps that need it.**
1185
1377
  Turf Monster's geo stack, lifted into the engine and generalised from
1186
1378
  US-states-only to country + subdivision. Full guide:
@@ -1236,6 +1428,51 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
1236
1428
  exist, and a *resolved foreign* country with no region stays allowed. Opt out
1237
1429
  with `Studio.geo_fail_closed = false`.
1238
1430
 
1431
+ ## 0.56.3 — 2026-08-19
1432
+
1433
+ ### Changed
1434
+
1435
+ - **Montserrat is vendored into the engine and can no longer re-measure the page.**
1436
+ `layouts/studio/_head` linked the family from `fonts.googleapis.com` with
1437
+ `display=swap`. The stylesheet blocks the load event; the font FILES do not — so
1438
+ every app went interactive in the fallback font and re-measured every glyph when
1439
+ Montserrat landed. That reflow swallowed a synthesized click three times in one CI
1440
+ day (root-caused in `close-board-filter-flake`: `pointerdown` and `pointerup` hit
1441
+ different elements, so the browser fired `click` on their common ancestor), and on a
1442
+ cold cache it moved a filter chip under a real finger. Montserrat was the last
1443
+ third-party asset in the head; Alpine, SortableJS and canvas-confetti were already
1444
+ vendored.
1445
+
1446
+ What ships:
1447
+
1448
+ - **Two woff2 files** — `studio/montserrat-latin.woff2` (35KB) and
1449
+ `studio/montserrat-latin-ext.woff2` (68KB), served through the asset pipeline and
1450
+ precompiled for Sprockets hosts. The `fonts.googleapis.com` and `fonts.gstatic.com`
1451
+ preconnects are gone, and the head reaches no third party at all.
1452
+ - **`font-display: optional`, not `swap`.** This is the fix, not the self-hosting.
1453
+ `swap` has an unbounded swap period, so a late font always reflows wherever it is
1454
+ served from; `optional` has none, so a font that misses the block period is
1455
+ abandoned for that navigation instead of shifting the layout. The cost is named: a
1456
+ first visit on a slow link can render one page in the fallback. A `crossorigin`
1457
+ preload of the latin subset is what keeps that rare.
1458
+ - **One face per subset spanning `100 900`.** Montserrat v31 is a VARIABLE font — the
1459
+ six weights the old link requested all resolved to the same bytes — so every weight
1460
+ the fleet uses renders from real outlines rather than a synthesized fake, and asking
1461
+ for fewer weights would not have saved a byte.
1462
+ - **Only latin and latin-ext.** cyrillic, cyrillic-ext and vietnamese occur zero times
1463
+ in the engine's views, the consumers' views, and their seed and locale data, so they
1464
+ are not shipped. `unicode-range` means a page only downloads the subsets it renders,
1465
+ so keeping latin-ext (for roster names like Modrić and Çalhanoğlu) costs an English
1466
+ page nothing.
1467
+
1468
+ No consumer action is required: the family, the weights, and the Tailwind `sans` stack
1469
+ are unchanged. An app that sets its own `font-display` or preloads Montserrat itself
1470
+ should drop that.
1471
+
1472
+ ## 0.56.0 — 2026-08-16
1473
+
1474
+ ### Added
1475
+
1239
1476
  - **The hold-to-confirm button, with its fizz — a new ACTION family in
1240
1477
  `engine-motion.css`.** A press-and-hold CTA for an action a host does not want
1241
1478
  taken by accident, ported from Turf Monster where it confirms a contest entry.
@@ -1283,6 +1520,10 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
1283
1520
  page matches the one it replaced) and two buttons on a page do not fizz in
1284
1521
  lockstep.
1285
1522
 
1523
+ ## 0.55.0 — 2026-08-16
1524
+
1525
+ ### Added
1526
+
1286
1527
  - **`.studio-team-glow` rings a host that paints its own background**, and can
1287
1528
  carry **two colors**.
1288
1529
 
@@ -1304,118 +1545,13 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
1304
1545
  defaults to the first, so one-color callers see no change and a caller with a
1305
1546
  pair to show — a Pokémon's two types — gets one per wedge.
1306
1547
 
1307
- The bloom twin needed care: a mask applies AFTER a filter, so its box carries a
1308
- transparent border twice the blur radius and its wedges stop at the padding
1309
- box. Without that room the halo ends on a hard line at its own edge.
1310
-
1311
- ### Changed
1312
-
1313
- - **Montserrat is vendored into the engine and can no longer re-measure the page.**
1314
- `layouts/studio/_head` linked the family from `fonts.googleapis.com` with
1315
- `display=swap`. The stylesheet blocks the load event; the font FILES do not — so
1316
- every app went interactive in the fallback font and re-measured every glyph when
1317
- Montserrat landed. That reflow swallowed a synthesized click three times in one CI
1318
- day (root-caused in `close-board-filter-flake`: `pointerdown` and `pointerup` hit
1319
- different elements, so the browser fired `click` on their common ancestor), and on a
1320
- cold cache it moved a filter chip under a real finger. Montserrat was the last
1321
- third-party asset in the head; Alpine, SortableJS and canvas-confetti were already
1322
- vendored.
1323
-
1324
- What ships:
1325
-
1326
- - **Two woff2 files** — `studio/montserrat-latin.woff2` (35KB) and
1327
- `studio/montserrat-latin-ext.woff2` (68KB), served through the asset pipeline and
1328
- precompiled for Sprockets hosts. The `fonts.googleapis.com` and `fonts.gstatic.com`
1329
- preconnects are gone, and the head reaches no third party at all.
1330
- - **`font-display: optional`, not `swap`.** This is the fix, not the self-hosting.
1331
- `swap` has an unbounded swap period, so a late font always reflows wherever it is
1332
- served from; `optional` has none, so a font that misses the block period is
1333
- abandoned for that navigation instead of shifting the layout. The cost is named: a
1334
- first visit on a slow link can render one page in the fallback. A `crossorigin`
1335
- preload of the latin subset is what keeps that rare.
1336
- - **One face per subset spanning `100 900`.** Montserrat v31 is a VARIABLE font — the
1337
- six weights the old link requested all resolved to the same bytes — so every weight
1338
- the fleet uses renders from real outlines rather than a synthesized fake, and asking
1339
- for fewer weights would not have saved a byte.
1340
- - **Only latin and latin-ext.** cyrillic, cyrillic-ext and vietnamese occur zero times
1341
- in the engine's views, the consumers' views, and their seed and locale data, so they
1342
- are not shipped. `unicode-range` means a page only downloads the subsets it renders,
1343
- so keeping latin-ext (for roster names like Modrić and Çalhanoğlu) costs an English
1344
- page nothing.
1345
-
1346
- No consumer action is required: the family, the weights, and the Tailwind `sans` stack
1347
- are unchanged. An app that sets its own `font-display` or preloads Montserrat itself
1348
- should drop that.
1349
-
1350
- ### Fixed
1351
-
1352
- - **The age gate's back link now returns the date the person already entered.**
1353
- `blocks/_age_gate`'s "Update your Birthday" swapped back to `blocks/_birthday`
1354
- with an EMPTY props object, discarding the `dobYear`/`dobMonth`/`dobDay` parts
1355
- the birthday factory had just handed across the store — and the factory started
1356
- its three fields at `""` and never read them anyway. So the correction path came
1357
- back BLANK: a mistyped year cost all three picks, on a card whose own header
1358
- comment promised "a correction, not a restart".
1359
-
1360
- Both sides of the seam moved. `back()` forwards the three date parts — and only
1361
- those three, deliberately: `minAge`, `state` and `message` describe the refusal,
1362
- and `validates` is load-bearing by its ABSENCE (the style guide specimen reads an
1363
- absent prop as validating, and a forwarded stale one would let the refusal pick
1364
- the next card's mode). `window.birthdayModal` gained an `init` that re-picks the
1365
- three selects from the props of the modal entry it mounted in, clamping a day
1366
- that the restored month cannot hold.
1367
-
1368
- **The gate is not weakened.** Restoring the date restores the DATE and never the
1369
- verdict: the card comes back submittable and the app's endpoint re-decides on the
1370
- next submit exactly as it did on the first. `e2e/birthday_gate.spec.js` asserts a
1371
- restored under-age date is refused again.
1372
-
1373
- **No consumer action.** A host that renders `blocks/_age_gate` and
1374
- `studio/_birthday_assets` gets this by upgrading; nothing in the call signature
1375
- changed and no host wiring moves.
1376
-
1377
- - **The Geo signpost now reaches every app whose admin chrome the engine owns.**
1378
- The row added last release went into `components/_admin_dropdown` alone, on the
1379
- premise that the shared dropdown reaches every app from one change. It does
1380
- not, and counted against the apps that actually consume this engine it reached
1381
- **none of them**. Wherever a host declares an admin-flagged sidebar section,
1382
- `studio_sidebar_replaces_admin_menu?` SUPPRESSES the dropdown and the link
1383
- sidebar is the admin menu — that is both `mcritchie-studio` and
1384
- `mcritchie-industries`. An app that forks its navbar outright renders neither,
1385
- which is `turf-monster`. Nobody was left on the plain engine navbar to see it,
1386
- so the app carrying the largest admin surface in the ecosystem was among those
1387
- guaranteed never to.
1388
-
1389
- The row moved into its own partial, **`components/_geo_signpost`**, and BOTH
1390
- engine chromes render it — the dropdown as before, and
1391
- `components/_link_sidebar` under its own admin-chipped `Geo` heading. Nothing
1392
- to configure: a host on either chrome gets the signage from the version bump.
1393
-
1394
- **Exactly one of them, never both.** The sidebar carries the row only where it
1395
- IS the admin menu (the host declared an admin-flagged section, so the dropdown
1396
- is suppressed). A host declaring only public sections renders BOTH chromes by
1397
- design, and there the dropdown keeps the row — fixing a reach gap by showing
1398
- an admin the same row twice is not a fix.
1399
-
1400
- **A forked chrome still needs one line**, because the engine cannot reach a
1401
- fork by rendering. Inside your own admin block:
1402
-
1403
- ```erb
1404
- <%= render "components/geo_signpost",
1405
- variant: :sidebar, close_action: "$store.sidebars.gearOpen = false" %>
1406
- ```
1407
-
1408
- `variant:` picks the chrome's row shape (`:dropdown`, the default, or
1409
- `:sidebar`); `close_action:` is the Alpine expression that closes YOUR panel
1410
- behind the row — the store flag belongs to the caller, so the partial never
1411
- hardcodes one. See [`docs/GEO.md`](docs/GEO.md#finding-the-page--the-signpost-in-whichever-chrome-the-app-has).
1412
-
1413
- Behavior is otherwise unchanged: same three states, same words, same
1414
- self-gating on `admin?`, and `ENABLE_GEO_BLOCKING` still governs the LINK and
1415
- never the gate. One test hook was renamed — `data-test="geo-signpost-disabled"`,
1416
- was `admin-dropdown-geo-disabled` — since the row renders in two chromes now
1417
- and a name claiming one of them was a lie. Grepped ecosystem-wide first: it
1418
- appeared in no consumer.
1548
+ The bloom twin needed care: a mask applies AFTER a filter, so its box carries a
1549
+ transparent border twice the blur radius and its wedges stop at the padding
1550
+ box. Without that room the halo ends on a hard line at its own edge.
1551
+
1552
+ ## 0.54.1 — 2026-08-15
1553
+
1554
+ ### Fixed
1419
1555
 
1420
1556
  - **`.conic-surface` keeps its wash inside its own box.** The wash was an
1421
1557
  OVERSIZED pseudo (`inset: -50%`) spun by a `transform` keyframe, cut back to
@@ -1450,6 +1586,8 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
1450
1586
  contract comment now all state the same thing. The primitive's geometry is
1451
1587
  unchanged — the consumers tuned against it are untouched.
1452
1588
 
1589
+ ## 0.54.0 — 2026-08-15
1590
+
1453
1591
  ### Added
1454
1592
 
1455
1593
  - **`studio/fields/_date_of_birth`** — the engine's one date-of-birth field,
@@ -1485,6 +1623,7 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
1485
1623
  clearing a day the new month lost, refusing a partial date, and a year list
1486
1624
  that stops at this year.
1487
1625
 
1626
+ ## 0.53.1 — 2026-08-15
1488
1627
 
1489
1628
  ### Changed
1490
1629
 
@@ -1502,6 +1641,7 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
1502
1641
  `position: fixed`, placed from the trigger's rect, so it never relied on that
1503
1642
  wrapper as a containing block.
1504
1643
 
1644
+ ## 0.53.0 — 2026-08-15
1505
1645
 
1506
1646
  ### Added
1507
1647
 
@@ -1534,7 +1674,6 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
1534
1674
  Fires on both verbs, so a host that only cares about joins ignores the flag.
1535
1675
  The default is inert.
1536
1676
 
1537
-
1538
1677
  ### Fixed
1539
1678
 
1540
1679
  - **The birthday calendar keeps the side it opened on.** `place()` runs on every
@@ -1552,6 +1691,7 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
1552
1691
  should be re-decided at all. The flip spec added with it opens and asserts
1553
1692
  once, so a side that changes **later** was invisible to it.
1554
1693
 
1694
+ ## 0.52.3 — 2026-08-15
1555
1695
 
1556
1696
  ### Fixed
1557
1697
 
@@ -1575,6 +1715,40 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
1575
1715
  fits. The new spec forces the flip, asserts the flip actually happened, and then
1576
1716
  asserts the popover's bottom edge sits against the trigger's top edge.
1577
1717
 
1718
+ ## 0.52.0 — 2026-08-15
1719
+
1720
+ ### Added
1721
+
1722
+ - **`/profile` gains the Newsletter row.** Lifted from turf-monster's `/account`
1723
+ card, which has run this flow in production, and stripped of everything
1724
+ turf-specific on the way: its 25-seed on-chain welcome bonus, its quest state,
1725
+ its seeds level-up payload. What the engine takes is the part every app needs.
1726
+
1727
+ New routes: `POST /profile/newsletter` (`profile_newsletter_path`) joins,
1728
+ `DELETE` on the same path leaves.
1729
+
1730
+ **TWO TIMESTAMPS, NOT A BOOLEAN.** `joined_email_list_at` and
1731
+ `left_email_list_at`, matching turf, because the pair carries three states a
1732
+ flag cannot: never asked (both nil), subscribed (joined after left, *including
1733
+ a rejoin where both are set*), and unsubscribed. `Studio::Newsletter` holds the
1734
+ rules — `subscribed?`, `ever_joined?`, `needs_email?` — pure and duck-typed
1735
+ like `Studio::OauthIdentity`.
1736
+
1737
+ `ever_joined?` is deliberately a different question from `subscribed?`: leaving
1738
+ stamps a date and never clears the join, so a consumer paying a once-ever
1739
+ welcome bonus cannot have it re-earned by cycling.
1740
+
1741
+ **ASYMMETRIC ON PURPOSE.** Joining is one click; leaving asks for confirmation
1742
+ in a modal. Joining is reversible from the same card, so a confirm step would
1743
+ be friction protecting nothing — a mis-click on leave is silent until the next
1744
+ send that never arrives. An account with **no address on file** (a wallet-only
1745
+ sign-in) is asked for one in a modal rather than allowed to submit and fail;
1746
+ the address is written but **not** marked verified, because typing an address
1747
+ is not the same as holding it.
1748
+
1749
+ Gated on `requires:` like every other row, so a host without the columns gets
1750
+ silence rather than a 500. The columns ship consumer-first under
1751
+ *Roll Out Standard Profile Columns*.
1578
1752
 
1579
1753
  ### Changed
1580
1754
 
@@ -1614,39 +1788,18 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
1614
1788
  The three integer columns are unchanged; the UI joins them for entry and
1615
1789
  `ProfilesController#update` splits them again.
1616
1790
 
1791
+ - **The read page mounts a modal host only when a row asks for one.** This is the
1792
+ registry's `modals:` key finally doing the job it was documented for — until
1793
+ the newsletter row, nothing on `/profile` opened a modal and mounting a host
1794
+ would have been furniture for nobody.
1617
1795
 
1618
- ### Added
1619
-
1620
- - **`/profile` gains the Newsletter row.** Lifted from turf-monster's `/account`
1621
- card, which has run this flow in production, and stripped of everything
1622
- turf-specific on the way: its 25-seed on-chain welcome bonus, its quest state,
1623
- its seeds level-up payload. What the engine takes is the part every app needs.
1624
-
1625
- New routes: `POST /profile/newsletter` (`profile_newsletter_path`) joins,
1626
- `DELETE` on the same path leaves.
1627
-
1628
- **TWO TIMESTAMPS, NOT A BOOLEAN.** `joined_email_list_at` and
1629
- `left_email_list_at`, matching turf, because the pair carries three states a
1630
- flag cannot: never asked (both nil), subscribed (joined after left, *including
1631
- a rejoin where both are set*), and unsubscribed. `Studio::Newsletter` holds the
1632
- rules — `subscribed?`, `ever_joined?`, `needs_email?` — pure and duck-typed
1633
- like `Studio::OauthIdentity`.
1634
-
1635
- `ever_joined?` is deliberately a different question from `subscribed?`: leaving
1636
- stamps a date and never clears the join, so a consumer paying a once-ever
1637
- welcome bonus cannot have it re-earned by cycling.
1638
-
1639
- **ASYMMETRIC ON PURPOSE.** Joining is one click; leaving asks for confirmation
1640
- in a modal. Joining is reversible from the same card, so a confirm step would
1641
- be friction protecting nothing — a mis-click on leave is silent until the next
1642
- send that never arrives. An account with **no address on file** (a wallet-only
1643
- sign-in) is asked for one in a modal rather than allowed to submit and fail;
1644
- the address is written but **not** marked verified, because typing an address
1645
- is not the same as holding it.
1796
+ `modals:` is now **a partial path rather than a boolean**. A host row that
1797
+ declares modals keeps its partial in the host's own app, so any convention like
1798
+ `"studio/profiles/#{key}_modals"` would resolve to a path that does not exist
1799
+ there. It costs one string and works for everyone.
1646
1800
 
1647
- Gated on `requires:` like every other row, so a host without the columns gets
1648
- silence rather than a 500. The columns ship consumer-first under
1649
- *Roll Out Standard Profile Columns*.
1801
+ The host is **not** the cropper: `/profile` mounts `studio/modals/_scoped_host`
1802
+ and never `studio/cropper_assets`, because the avatar is read-only on that page.
1650
1803
 
1651
1804
  ### Fixed
1652
1805
 
@@ -1668,21 +1821,7 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
1668
1821
  first browser spec — the view suite was green on the `@click` attribute
1669
1822
  throughout.
1670
1823
 
1671
- ### Changed
1672
-
1673
- - **The read page mounts a modal host only when a row asks for one.** This is the
1674
- registry's `modals:` key finally doing the job it was documented for — until
1675
- the newsletter row, nothing on `/profile` opened a modal and mounting a host
1676
- would have been furniture for nobody.
1677
-
1678
- `modals:` is now **a partial path rather than a boolean**. A host row that
1679
- declares modals keeps its partial in the host's own app, so any convention like
1680
- `"studio/profiles/#{key}_modals"` would resolve to a path that does not exist
1681
- there. It costs one string and works for everyone.
1682
-
1683
- The host is **not** the cropper: `/profile` mounts `studio/modals/_scoped_host`
1684
- and never `studio/cropper_assets`, because the avatar is read-only on that page.
1685
-
1824
+ ## 0.50.0 — 2026-08-15
1686
1825
 
1687
1826
  ### Added
1688
1827
 
@@ -1719,19 +1858,6 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
1719
1858
  the intended consequence — the sidebar always has at least the viewer's own
1720
1859
  profile in it — but it is a visible change to a navbar every consumer inherits.
1721
1860
 
1722
- ### Fixed
1723
-
1724
- - **`Studio.profile_sections`' `if:` gate no longer fails open on a Symbol.**
1725
- `if: :some_predicate` — Rails' own `before_action ..., if: :method_name`
1726
- spelling, and so the most natural thing a host will write — was coerced
1727
- straight to `true`, because a Symbol does not answer `call`. The gate silently
1728
- did nothing, with no signal to the host, in the same permissive direction as
1729
- the bug `if:` was added to fix. Symbols and Strings now name a method on the
1730
- view; a name the view does not answer drops the row rather than rendering it.
1731
- Lambdas and plain booleans are unchanged.
1732
-
1733
- ### Added
1734
-
1735
1861
  - **`/profile` splits into a read page and an edit page.** `/profile` is now "you
1736
1862
  at a glance" — the identity header, and the rows you look at rather than type
1737
1863
  into. `/profile/edit` (`edit_profile_path`) holds the fields, in ONE form with
@@ -1760,6 +1886,10 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
1760
1886
  adding a second copy for a screen reader to read out. Honors
1761
1887
  `prefers-reduced-motion`.
1762
1888
 
1889
+ ## 0.49.0 — 2026-08-15
1890
+
1891
+ ### Added
1892
+
1763
1893
  - **`/profile` gains the Email field — changeable from any signed-in session.**
1764
1894
  It saves with the rest of the edit form through `PATCH /profile`.
1765
1895
 
@@ -1791,6 +1921,10 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
1791
1921
  not raise, it would quietly send TURF's copy, banner and `account_url` instead
1792
1922
  of the engine's. The silent case is the dangerous one.
1793
1923
 
1924
+ ## 0.48.0 — 2026-08-15
1925
+
1926
+ ### Added
1927
+
1794
1928
  - **`/profile` gains the Google account row.** Shows the linked identity with an
1795
1929
  Unlink control, or a branded Connect button that POSTs to OmniAuth's own
1796
1930
  `/auth/google_oauth2` (the engine does not draw a link route — the middleware
@@ -1984,6 +2118,21 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
1984
2118
  mcritchie-industries and acquisition-studio gain a working link where they had
1985
2119
  `href="#"`; every other consumer forks the partial and is untouched.
1986
2120
 
2121
+ ### Fixed
2122
+
2123
+ - **`Studio.profile_sections`' `if:` gate no longer fails open on a Symbol.**
2124
+ `if: :some_predicate` — Rails' own `before_action ..., if: :method_name`
2125
+ spelling, and so the most natural thing a host will write — was coerced
2126
+ straight to `true`, because a Symbol does not answer `call`. The gate silently
2127
+ did nothing, with no signal to the host, in the same permissive direction as
2128
+ the bug `if:` was added to fix. Symbols and Strings now name a method on the
2129
+ view; a name the view does not answer drops the row rather than rendering it.
2130
+ Lambdas and plain booleans are unchanged.
2131
+
2132
+ ## 0.47.2 — 2026-08-14
2133
+
2134
+ ### Changed
2135
+
1987
2136
  - **No pre-registered email seeds a logo — `magic_link` was the last one, and it
1988
2137
  seeded the Studio wordmark onto the SIGN-IN email.** `STANDARD`'s `magic_link`
1989
2138
  entry carried `logo: "emails/logo-horizontal.png"`, and
@@ -2033,32 +2182,7 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
2033
2182
  that registers no mark, so the page says so rather than offering a choice that
2034
2183
  changes nothing.
2035
2184
 
2036
- - **`email_change_confirmation` leaves the pre-registered set; `newsletter_subscribed`
2037
- joins it.** `STANDARD` means "the emails EVERY Studio app sends", and only
2038
- turf-monster sends an email-change confirmation — which it **already registers
2039
- itself**, with its own artwork, in `config/initializers/studio_emails.rb`. No
2040
- host loses a banner, and no host action is required for the removal.
2041
-
2042
- What DOES break consumers is the ADDITION. Three suites assert against the
2043
- whole shared catalogue, so a new standard email fails them:
2044
-
2045
- | Consumer | Assertion | Fix | State |
2046
- |---|---|---|---|
2047
- | turf-monster | `EmailRegistrationTest` compares the full key list | McRitchie-Studio/turf-monster#292 | merged |
2048
- | turf-monster | `AdminEmailsRenderTest` counts `tbody img` exactly | McRitchie-Studio/turf-monster#294 | merged |
2049
- | mcritchie-studio | `StudioEmailsPageTest` pins the registry list, an `email_change_confirmation` link, and the upload-rejection wording | McRitchie-Studio/mcritchie-studio#813 | merged |
2050
-
2051
- The second turf fix is the easy one to miss: a layered standard email renders
2052
- its banner through an `<iframe>` rather than an `<img>`, so an exact image
2053
- count comes up one short. #292 alone does NOT turn that lane green.
2054
-
2055
- Consumer CI builds each app against its **default branch**, so all three had to
2056
- reach `main` before this engine's consumer lanes could go green. All three now
2057
- have, and the lanes pass. Nothing on this branch ever could have turned them
2058
- green — a red consumer lane here is a consumer-side fix, not an engine defect.
2059
-
2060
- The artwork `emails/email-change-confirmation.gif` still ships, so a host that
2061
- wants the entry can register it.
2185
+ ## 0.47.0 2026-08-13
2062
2186
 
2063
2187
  ### Added
2064
2188
 
@@ -2116,6 +2240,10 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
2116
2240
  exact defect `isLive` exists to prevent. A turf-side follow-up, not part of
2117
2241
  this change.
2118
2242
 
2243
+ ## 0.46.0 — 2026-08-13
2244
+
2245
+ ### Added
2246
+
2119
2247
  - **The standard user profile columns — and the engine's first migration against
2120
2248
  a host-owned table.** Every other engine migration creates a `studio_*` table
2121
2249
  the engine owns outright; `users` belongs to the host. That boundary is crossed
@@ -2193,6 +2321,46 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
2193
2321
  rather than raising on every signed-in request — so the gem can land there
2194
2322
  before the migration does.
2195
2323
 
2324
+ ## 0.44.1 — 2026-08-13
2325
+
2326
+ ### Fixed
2327
+
2328
+ - **The newsletter's seed lent every host the McRitchie Studio wordmark.**
2329
+ `STANDARD`'s `newsletter_subscribed` entry seeded
2330
+ `logo: "emails/logo-horizontal.png"`, and `EmailCatalog.register` merges on
2331
+ `logo.presence` — so an omitted `logo:` INHERITS. A host registering that email
2332
+ for any ordinary reason (a relabel, its own preview builder) would have sent its
2333
+ branded newsletter under Studio's mark, and the alt text could not warn anyone:
2334
+ `Studio::Banner` sets it from `Studio.app_name`, so it always agrees with the
2335
+ host and never with the pixels. turf-monster hit the same mechanism on
2336
+ `magic_link`.
2337
+
2338
+ **No consumer was exposed.** No app registers `newsletter_subscribed` or sends
2339
+ it — turf's own `NewsletterMailer` sends its flat `newsletter_welcome` — so this
2340
+ was visible only on the `/admin/emails` preview. It was seeded identically in
2341
+ 0.42 and 0.43; nothing changed it recently.
2342
+
2343
+ The seed now carries no logo. **Artwork still rides the gem** — a background is
2344
+ a picture any app can send, and inheriting one is what gives a new app
2345
+ good-looking mail on day one. A wordmark is somebody's identity, and the line is
2346
+ drawn there. An app that wants a mark registers a path IT ships
2347
+ (`register("newsletter_subscribed", logo: "emails/our-mark.png")`) or sets one
2348
+ on /admin/emails. Guards render the mail and resolve every `<img>` back to a
2349
+ file on the engine's own asset load path, comparing the wordmark by its BYTES,
2350
+ so renaming the file and re-seeding it is caught too.
2351
+
2352
+ **`magic_link` is unchanged and still seeds the wordmark.** A host that
2353
+ registers no mark of its own inherits it on the sign-in email. That is an
2354
+ **upgrade trap** rather than a live leak — the apps with no email initializer
2355
+ are pinned before this registry existed, so they inherit nothing today, but the
2356
+ bump that brings one forward would hand it another brand's mark with no code
2357
+ change of its own. Tracked separately, with the design question of whether
2358
+ `STANDARD` should seed a logo at all.
2359
+
2360
+ ## 0.42.0 — 2026-08-12
2361
+
2362
+ ### Added
2363
+
2196
2364
  - **`Studio::NewsletterMailer` — a sendable "you're on the list" email.**
2197
2365
  Namespaced under `Studio::` on purpose: a host that defines its own top-level
2198
2366
  `UserMailer` (McRitchie Studio does) SHADOWS the engine's outright, so an
@@ -2245,39 +2413,36 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
2245
2413
  host's own users with avatar and initials, degrading to a sample when an app has
2246
2414
  none.
2247
2415
 
2248
- ### Fixed
2416
+ ### Changed
2249
2417
 
2250
- - **The newsletter's seed lent every host the McRitchie Studio wordmark.**
2251
- `STANDARD`'s `newsletter_subscribed` entry seeded
2252
- `logo: "emails/logo-horizontal.png"`, and `EmailCatalog.register` merges on
2253
- `logo.presence` so an omitted `logo:` INHERITS. A host registering that email
2254
- for any ordinary reason (a relabel, its own preview builder) would have sent its
2255
- branded newsletter under Studio's mark, and the alt text could not warn anyone:
2256
- `Studio::Banner` sets it from `Studio.app_name`, so it always agrees with the
2257
- host and never with the pixels. turf-monster hit the same mechanism on
2258
- `magic_link`.
2418
+ - **`email_change_confirmation` leaves the pre-registered set; `newsletter_subscribed`
2419
+ joins it.** `STANDARD` means "the emails EVERY Studio app sends", and only
2420
+ turf-monster sends an email-change confirmation which it **already registers
2421
+ itself**, with its own artwork, in `config/initializers/studio_emails.rb`. No
2422
+ host loses a banner, and no host action is required for the removal.
2259
2423
 
2260
- **No consumer was exposed.** No app registers `newsletter_subscribed` or sends
2261
- it turf's own `NewsletterMailer` sends its flat `newsletter_welcome` — so this
2262
- was visible only on the `/admin/emails` preview. It was seeded identically in
2263
- 0.42 and 0.43; nothing changed it recently.
2424
+ What DOES break consumers is the ADDITION. Three suites assert against the
2425
+ whole shared catalogue, so a new standard email fails them:
2264
2426
 
2265
- The seed now carries no logo. **Artwork still rides the gem** — a background is
2266
- a picture any app can send, and inheriting one is what gives a new app
2267
- good-looking mail on day one. A wordmark is somebody's identity, and the line is
2268
- drawn there. An app that wants a mark registers a path IT ships
2269
- (`register("newsletter_subscribed", logo: "emails/our-mark.png")`) or sets one
2270
- on /admin/emails. Guards render the mail and resolve every `<img>` back to a
2271
- file on the engine's own asset load path, comparing the wordmark by its BYTES,
2272
- so renaming the file and re-seeding it is caught too.
2427
+ | Consumer | Assertion | Fix | State |
2428
+ |---|---|---|---|
2429
+ | turf-monster | `EmailRegistrationTest` compares the full key list | McRitchie-Studio/turf-monster#292 | merged |
2430
+ | turf-monster | `AdminEmailsRenderTest` counts `tbody img` exactly | McRitchie-Studio/turf-monster#294 | merged |
2431
+ | mcritchie-studio | `StudioEmailsPageTest` pins the registry list, an `email_change_confirmation` link, and the upload-rejection wording | McRitchie-Studio/mcritchie-studio#813 | merged |
2273
2432
 
2274
- **`magic_link` is unchanged and still seeds the wordmark.** A host that
2275
- registers no mark of its own inherits it on the sign-in email. That is an
2276
- **upgrade trap** rather than a live leak the apps with no email initializer
2277
- are pinned before this registry existed, so they inherit nothing today, but the
2278
- bump that brings one forward would hand it another brand's mark with no code
2279
- change of its own. Tracked separately, with the design question of whether
2280
- `STANDARD` should seed a logo at all.
2433
+ The second turf fix is the easy one to miss: a layered standard email renders
2434
+ its banner through an `<iframe>` rather than an `<img>`, so an exact image
2435
+ count comes up one short. #292 alone does NOT turn that lane green.
2436
+
2437
+ Consumer CI builds each app against its **default branch**, so all three had to
2438
+ reach `main` before this engine's consumer lanes could go green. All three now
2439
+ have, and the lanes pass. Nothing on this branch ever could have turned them
2440
+ green — a red consumer lane here is a consumer-side fix, not an engine defect.
2441
+
2442
+ The artwork `emails/email-change-confirmation.gif` still ships, so a host that
2443
+ wants the entry can register it.
2444
+
2445
+ ### Fixed
2281
2446
 
2282
2447
  - **The manager reported artwork that was not being sent.** A layered-native email
2283
2448
  (no flat `default_asset`) drew an empty box and a "sends without a banner" badge
@@ -2291,6 +2456,8 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
2291
2456
  existing guard was real and only ran on the index, so it never saw the page that
2292
2457
  shipped it.
2293
2458
 
2459
+ ## 0.41.0 — 2026-08-12
2460
+
2294
2461
  ### Added
2295
2462
 
2296
2463
  - **A browser lane — the engine now runs Playwright against its own partials.**
@@ -2312,6 +2479,10 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
2312
2479
  and covered instead by `test/views/at_time_zone_table_test.rb`. Full rationale,
2313
2480
  limits and cost: `docs/E2E_LANE.md`.
2314
2481
 
2482
+ ## 0.40.0 — 2026-08-12
2483
+
2484
+ ### Added
2485
+
2315
2486
  - **The "at" time stamp — `at_time_tag` + `studio/at_time_script`.** A shared
2316
2487
  primitive for stamping WHEN something happened, on the READER's clock rather
2317
2488
  than the app's: `at 3:53p`, gaining a date only when the stamp is not today and