studio-engine 0.74.7 → 0.74.9

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,11 +1,79 @@
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)). Write entries under `## Unreleased` and never write a version heading: when `prepare` allocates a version it rolls this file in the same commit, moving everything under `## Unreleased` beneath a new `## <version> — <date>` heading (written even when the bucket is empty) and leaving `## Unreleased` first and empty. It refuses the sweep, with nothing published, when the roll would lie or it cannot read this file; when it skips allocation, nothing rolls. [`docs/RELEASE.md`](./docs/RELEASE.md), *Rolling `Unreleased` into a version*, says when each happens and what to do by hand. `test/docs/changelog_structure_test.rb` guards the shape.
4
4
 
5
5
  ## Unreleased
6
6
 
7
7
  ### Fixed
8
8
 
9
+ - **The auth resend footer's error line failed WCAG AA in both themes; it now
10
+ uses the danger ink.** `studio/modals/auth/_resend_footer` painted its
11
+ `role="alert"` paragraph with the static `text-red-400`, which Tailwind v4
12
+ compiles to about `#FF6467`: 3.86:1 on the dark modal card and 2.89:1 on the
13
+ light one, both under the 4.5:1 that 12px text needs. It is now
14
+ `text-danger-ink`, the red `Studio::ThemeResolver` derives per theme to clear
15
+ 4.5:1 on every surface: 4.50:1 dark and 5.76:1 light on the default modal
16
+ card. The resend spinner stays the engine's `.spinner`, tuned to
17
+ turf-monster's currentColor ring (`--spinner-track: currentColor;
18
+ --spinner-color: transparent`), so a host that drops its fork sees no change.
19
+ Guarded by `test/views/resend_footer_error_contrast_test.rb`.
20
+
21
+ ### Docs
22
+
23
+ - **Thirty-five minor versions of shipped entries left `## Unreleased` and moved
24
+ under the version that actually shipped them.** The block spanned 2,382 lines and every
25
+ entry in it had already been published — `accepted` sat on the `v0.74.4` release
26
+ commit when they were rolled — so the heading said "pending" about the whole history of the gem, and
27
+ each reviewer who opened the file had to settle per entry which half it was in.
28
+ 85 entries plus three unbulleted prose blocks now sit under 46 version headings
29
+ from `0.40.0` to `0.74.3`; `## Unreleased` holds only this note.
30
+
31
+ **Bookkeeping only — no entry was reworded, added or dropped.** Attribution came
32
+ from git, never from position in the file: `git blame -w -M` on each entry, the
33
+ pickaxe (`git log -S`) for its introducing commit, and a scan of `CHANGELOG.md`
34
+ at all 81 release tags from `v0.39.0` up for the earliest tag whose file already
35
+ contained the entry's text. All three agreed on 81 of 85; each of the four splits
36
+ was a false `-M` match on boilerplate — a bare fenced-Ruby opener, a
37
+ `bin/rails … install:migrations` line — and resolved to the majority of five
38
+ per-entry probes. Verified as a permutation: 1,991 content lines before, the
39
+ same 1,991 after, none added, none lost.
40
+
41
+ **The cause, and the guard.** `bin/release prepare` commits `version.rb` with its
42
+ lockfile, publishes and tags — it never touches this file, and nothing failed
43
+ when the roll was skipped. `test/docs/changelog_structure_test.rb` now asserts the
44
+ file's SHAPE, never its prose: one leading `## Unreleased`, strictly decreasing
45
+ versions, no duplicate version, no orphan `###`, a parse floor so a rotted regex
46
+ fails loudly rather than passing vacuously, and — the one that would have caught
47
+ this in week one — `Studio::VERSION` no more than two minor versions ahead of the
48
+ newest heading. Against the pre-change file it reports "35 minor versions of
49
+ entries are still filed under `## Unreleased`". ~~Automating the roll inside
50
+ `bin/release prepare` (mcritchie-studio) is the real fix and is not done.~~ —
51
+ **superseded after 0.74.8:** mcritchie-studio#1344 made `prepare` roll this
52
+ file whenever it allocates a version, so "it never touches this file" above
53
+ describes the tooling as of 0.74.8. See the entry on the automatic roll below.
54
+
55
+ - **The docs called the `## Unreleased` roll a manual conductor step; `bin/release
56
+ prepare` does it now, and the docs say exactly when it does not.**
57
+ mcritchie-studio#1344 made prepare roll this file into the allocated version in
58
+ the commit that sets `lib/studio/version.rb` and `Gemfile.lock`, and REFUSE the
59
+ sweep, with nothing published, when the roll would lie. `docs/RELEASE.md`
60
+ (*Rolling `Unreleased` into a version*), this file's preamble and the README's
61
+ release paragraph still said the roll was yours, by hand. They now name the
62
+ three outcomes (ALLOCATE rolls, SKIP rolls nothing, REFUSE aborts), each
63
+ refusal and its fix, and the reach limit: a SKIP (a version set by hand, or a
64
+ re-run after an abort before the tag) rolls nothing. They also settle three
65
+ conventions. An entry-less release still gets its heading; a heading never
66
+ lands before its version does, because `accepted` lags `release`; and a merge
67
+ that crosses a roll gets checked, because git can merge a new bullet CLEAN
68
+ under a version that shipped without it. The manual procedure stays, as the
69
+ remedy for those cases. No runtime code changed;
70
+ `test/docs/changelog_structure_test.rb` changed only its comments and its
71
+ failure message.
72
+
73
+ ## 0.74.3 — 2026-09-08
74
+
75
+ ### Fixed
76
+
9
77
  - **A store name that is not a JS identifier half-worked, silently, in seventeen
10
78
  partials — it now raises.** `$store.<%= modal_store %>.close()` splices the local
11
79
  in as a bare NAME, so `modal_store: "my store"` emitted
@@ -52,6 +120,10 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
52
120
  regex inline as the worked example and now calls the shared one — same pattern,
53
121
  same error, same message.
54
122
 
123
+ ## 0.74.2 — 2026-09-08
124
+
125
+ ### Fixed
126
+
55
127
  - **The email banner assembled two attributes INCLUDING THEIR OWN QUOTES in Ruby,
56
128
  so ERB escaping never ran on them.** `_layered_banner.html.erb` wrote
57
129
  `background="..."` and `bgcolor="..."` as Ruby strings and marked them
@@ -130,6 +202,10 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
130
202
  FOUR, so a false completeness claim was shipping one sentence above the entry
131
203
  announcing a fix for exactly that.
132
204
 
205
+ ## 0.74.1 — 2026-09-08
206
+
207
+ ### Fixed
208
+
133
209
  - **A quote in a host local can no longer close an Alpine attribute and turn the
134
210
  rest of the element into markup.** Four partials built an attribute — its OWN
135
211
  QUOTES included — as a Ruby String and marked it `html_safe`, which is the one
@@ -242,6 +318,10 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
242
318
  already clean. Both claims are corrected, and the comment now points at the test
243
319
  rather than restating that the sweep is done.
244
320
 
321
+ ## 0.74.0 — 2026-09-08
322
+
323
+ ### Fixed
324
+
245
325
  - **The app census in these comments was short by the most important app.** Ten
246
326
  sites said this engine is mounted by SIX apps and that THREE of them bundle no
247
327
  `solana-studio`. Measured 2026-09-07 by the criterion that reproduces it — a
@@ -285,6 +365,164 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
285
365
 
286
366
  Comment and prose only. No behaviour changes.
287
367
 
368
+ - **A host's apostrophe no longer kills the card it was passed to — now across the
369
+ blocks, not just the first-name step.** The same defect
370
+ `onboarding/_first_name` fixed one release ago turns out to be an engine-wide
371
+ idiom: a host-supplied local interpolated into a JS string literal inside a
372
+ JS-evaluating attribute. Repaired at eleven more splices.
373
+
374
+ **WHY IT IS WORTH A CHANGELOG LINE WHEN NOTHING VISIBLE CHANGES.** The failure
375
+ mode is silent. A bare apostrophe closes the JS literal, the whole expression
376
+ becomes a SyntaxError, and Alpine mounts the component as a NO-OP that still
377
+ renders every element — a card that looks perfect and whose buttons do nothing.
378
+ There is no error on screen, nothing in the server log, and no markup assertion
379
+ that can see it. Every value in every consumer resolves to a source literal or a
380
+ frozen constant today, so this is latent cover rather than a live fix; it is
381
+ worth doing because the next local to carry prose will look like an ordinary
382
+ change to whoever writes it.
383
+
384
+ **WHAT MOVED.** `blocks/_success_card` (`cta_event` at both CTA branches,
385
+ `secondary_event`), `blocks/_error_card` (`cta_event`, `secondary_event`),
386
+ `blocks/_entry_confirmed` and `blocks/_solana_tx_link` (`cluster_param`),
387
+ `modals/_crop_photo` (`store`), `studio/emails/show` (the two upload filenames
388
+ and the success sentence), and `profiles/_birthday_fields` (the date value).
389
+ No default or in-repo value contains a character either escaper touches, so
390
+ every shipped card renders byte-for-byte what it did.
391
+
392
+ **THE MECHANISM NOW HAS ONE HOME AND ONE GUARD.** `Studio::JsLiteral.in_attribute`
393
+ replaces the four inline copies in `_first_name`. TWO escapers have to run — one
394
+ for the JS literal, one for the HTML attribute — and the second only runs on a
395
+ value ERB still believes is unsafe, which is why the value is interpolated before
396
+ it is escaped. That subtlety was re-derived at every call site and had no test
397
+ anywhere; deleting it used to leave the suite green.
398
+
399
+ **NOT A FIX FOR IDENTIFIER POSITION, deliberately.** A local spliced in as a bare
400
+ NAME — `$store.<name>.close()` — must be VALIDATED, never escaped, because
401
+ `escape_javascript` also escapes `$` and mangles a legal store name. That fleet
402
+ (about 36 splices across 19 partials) and a third class found alongside it —
403
+ Ruby-ASSEMBLED JS emitted into an attribute, some of it already `html_safe` — are
404
+ scoped OUT of this change and carry their own tickets. The SHAPE of the splice
405
+ decides the repair, never the name of the local.
406
+
407
+ - **`Studio::S3` no longer mistakes a Rails NAMESPACE for a Rails application.**
408
+ `environment` guarded on `defined?(Rails)` and then called `Rails.env`.
409
+ rails-html-sanitizer — a transitive dependency of `action_view`, which arrives
410
+ long before any application does — ships a namespace-only `module Rails` with no
411
+ singleton methods, so that guard reads true and the next call raises
412
+ `NoMethodError: undefined method 'env' for module Rails`. It now asks
413
+ `Rails.respond_to?(:env)`, which is the form `lib/studio.rb` now uses in FOUR
414
+ places: the three it already carried (lines 701, 707 and 742) plus the route
415
+ guard at line 859. **This entry originally called it "the straggler"; that was
416
+ wrong** — three more sites carried the bare form, line 859 among them, and all
417
+ three are swept in *The Rails guard sweep really is finished now* above, in this
418
+ same unreleased block. No shipped app can reach it — every host boots a
419
+ real application — but the engine's own pure-Ruby unit lane can, and it did:
420
+ adding one `require` for a file that needs `action_view` turned an untouched
421
+ `email_catalog_test` red with two errors about email uploads.
422
+
423
+ ## 0.72.3 — 2026-09-07
424
+
425
+ ### Changed
426
+
427
+ - **The pinned stack composes itself, and publishes in the frame the change
428
+ happened.** `data-pin` layers now also publish `--pin-stack-bottom` (the bottom
429
+ of the whole stack) and `--pin-<name>-top` (the bottom of everything above that
430
+ layer), and the publisher writes them from inside its ResizeObserver callback
431
+ rather than deferring to `requestAnimationFrame`.
432
+
433
+ **THE DEFECT.** A frame runs `rAF -> style/layout -> ResizeObserver -> paint`,
434
+ so a write made in the frame callback lands at the top of the NEXT frame. Every
435
+ frame in which a pinned layer appeared or disappeared therefore painted with the
436
+ previous frame's number. Measured in isolation by toggling a layer's `display`
437
+ and sampling what the changing frame paints: rAF-deferred wrong on 8/8 changing
438
+ frames, synchronous wrong on 0/8. On mcritchie-studio's production
439
+ `/deployments` it showed as the board's swim-lane headers slamming 99px and back
440
+ on a page nobody was touching — parked at a fixed scroll offset with document
441
+ height, row position and nav height all constant, the header's `top` went
442
+ 152 -> 53 -> 152 -> 53px in 240ms behind two Turbo broadcasts.
443
+
444
+ **AND CONSUMERS NO LONGER COMPOSE THE STACK.** `top: max(var(--pin-nav-bottom),
445
+ var(--pin-apps-bottom))` fails twice: it does not scale, because a fourth layer
446
+ means editing every consumer that ever wanted to sit under the stack; and it is
447
+ not sound, because a `max()` over two custom properties is only meaningful if
448
+ they were written in the same frame. `--pin-stack-bottom` is one value that
449
+ cannot disagree with itself. A layer that is ITSELF in the stack reads
450
+ `--pin-<name>-top` instead, which excludes its own edge — positioning off the
451
+ stack bottom would make it chase itself down the page. Stacking order is
452
+ document order, with `data-pin-order` overriding it for a layer whose DOM
453
+ position does not match where it sits on screen.
454
+
455
+ **THE REGISTRY IS DERIVED PER PUBLISH, never cached.** A Turbo Stream replaces a
456
+ pinned layer's node without a `turbo:load`, and a held reference then points at
457
+ the DETACHED predecessor, whose rect is all zeros — indistinguishable from a
458
+ layer that is legitimately hidden. Re-querying makes the stale reference
459
+ impossible rather than merely unlikely.
460
+
461
+ **THE COST, re-measured**, because the rule this reverses was itself justified by
462
+ a measurement (turf-monster, 6x CPU throttle, frames over 20ms 13/24 through the
463
+ collapse ramp vs 0/24 once the observer was coalesced). What was expensive there
464
+ was read-write thrash, not the observer: the publisher now takes one read pass
465
+ and one write pass per invocation, and in an RO callback layout is already clean,
466
+ so those reads force nothing. On the engine lab at the same 6x throttle, ~700
467
+ frames of ramp each way — `RO through rAF` median 8.3ms / p90 10.0ms / 0 frames
468
+ over 20ms; `RO synchronous` median 8.4-8.5ms / p90 11.0ms / 3-6 frames over 20ms;
469
+ ZERO frames over 32ms on either, so neither drops one.
470
+
471
+ **BACK-COMPAT.** `--nav-h`, `--nav-bottom` and `--pin-<name>-h` /
472
+ `--pin-<name>-bottom` publish exactly as before, from the same measurements, so
473
+ the 26 consumers across turf-monster, rolio and mcritchie-studio need no change
474
+ and no floor bump. A structural change (a Turbo patch, a fresh document, a layer
475
+ joining or leaving) now forces a full write, because the unchanged-write skip is
476
+ only safe while nothing else touches these properties — the lab caught
477
+ `--nav-h` staying empty through a re-scan on exactly the host-owned-header path
478
+ both live consumers use. The lane contract moves 131 -> 132
479
+ (`config/e2e_lane.yml`), re-derived with the lister.
480
+
481
+ ## 0.72.2 — 2026-09-07
482
+
483
+ ### Fixed
484
+
485
+ - **The style guide's two "Sign Wallet" thumbnails no longer crown themselves
486
+ with a padlock the card stopped drawing, and the guide's lock no longer trails
487
+ the release that removed it.** solana-studio 0.6.1 replaced the step-up card's
488
+ padlock with the remembered wallet's own brand mark, falling back to a neutral
489
+ billfold where no brand is remembered. Both hand-drawn thumbnails in
490
+ `style/_modals` went on drawing the padlock, so the first thing a designer
491
+ reads advertised a glyph the design system had retired.
492
+
493
+ **THE THUMBNAILS WERE HALF OF IT.** The opened specimen renders the REAL
494
+ shared partial, but it does so through whatever `Gemfile.lock` resolves — so
495
+ it is only ever as current as the lock, and the lock sat on 0.6.0 while
496
+ turf-monster shipped 0.6.1. The guide's opened card was still drawing the
497
+ padlock too, which `bin/gem-drift-check` had already been failing on
498
+ (`engine 0.6.0 TRAILS turf-monster 0.6.1`). Correcting the sketches alone
499
+ would have put a brand mark on the thumbnail and a padlock in the card it
500
+ opens — moving the contradiction onto one screen rather than removing it. So
501
+ the lock had to move too, and it has: `accepted` carried the engine to
502
+ solana-studio 0.7.0 while this change sat in review, which is past the 0.6.1
503
+ that removed the padlock. This change no longer moves the lock itself — it
504
+ defers to the line `accepted` already holds — so the sketch and the card it
505
+ opens agree without it.
506
+
507
+ The sketches follow the card: a centered brand tile where a wallet is
508
+ remembered, the card's own billfold outline in an inset square where none is.
509
+ They stay SKETCHES rather than real brand marks for a mechanical reason worth
510
+ recording — every `se-wallet` symbol on the guide is defined inside a
511
+ `template x-if`, whose content is inert until Alpine clones it, so a `use`
512
+ placed outside one resolves to nothing and paints an empty box.
513
+
514
+ Guarded by `test/views/step_up_specimen_thumbnails_test.rb`, which slices each
515
+ thumbnail by a `data-test` hook before asserting. That slicing is the point:
516
+ the rendered guide already carried 17 `se-wallet-` hits from the picker and
517
+ the card's own CTA, so a page-wide assertion for the mark could not fail, and
518
+ a page-wide assertion against the padlock could not pass — the drag-board
519
+ specimen's prose legitimately contains one. The padlock is pinned in BOTH the
520
+ codepoint and the HTML-entity form. MEASURED: five mutants, five killed —
521
+ the padlock restored in either form, either header mark removed, and the lock
522
+ reverted to 0.6.0 each reddens the assertion that owns it.
523
+
524
+ ## 0.72.0 — 2026-09-07
525
+
288
526
  ### Added
289
527
 
290
528
  - **The first-name card now says WHICH path finished it, and can type its
@@ -344,6 +582,10 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
344
582
  test, while forcing the typed mode on, un-guarding the refocus, and treating
345
583
  an empty pool as present each hit a placeholder test.
346
584
 
585
+ ## 0.71.0 — 2026-09-07
586
+
587
+ ### Added
588
+
347
589
  - **The living style guide grows a HOST SECTION seam.** `/admin/style` rendered
348
590
  four hard-coded engine sections (Theme, Modals, Tricks, Tasks) with no way for
349
591
  a consuming app to contribute one of its own. An app now defines
@@ -377,6 +619,11 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
377
619
 
378
620
  **The seam ships no host section itself.** The first consumer is
379
621
  turf-monster's own `style/host/_modals`, which lands as its own task.
622
+
623
+ ## 0.70.0 — 2026-09-06
624
+
625
+ ### Added
626
+
380
627
  - **A `required` mode on the shared first-name card.**
381
628
  `studio/modals/onboarding/_first_name` grows one local. `required: true`
382
629
  renders the card with BOTH skip affordances gone — the "Skip for now" button
@@ -414,21 +661,6 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
414
661
  runtime — which is how the two cards already differ in copy, not just in
415
662
  affordances.
416
663
 
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
664
  - **The style guide grows the two modal simulators that review ENGINE
433
665
  behaviour** — `#modals-motion-registry` ("Enter & leave animations") and
434
666
  `#modals-stack-mechanics` ("Stack behaviour") in `style/_modals`. Both are
@@ -464,212 +696,35 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
464
696
  reintroduced, with the lab server restarted between runs. The lane contract
465
697
  moves 116 → 123 (`config/e2e_lane.yml`), re-derived with the lister.
466
698
 
467
- ### Changed
699
+ ### Fixed
468
700
 
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`.
701
+ - **The style guide's page-scoped modal store now resolves animations through
702
+ the LIVE registry, so the guide can no longer disagree with itself.**
703
+ `style/_modals`' `dsModals` carried a hard-coded COPY of the animation table
704
+ while the new simulator builds its controls from `window.ModalAnimations`.
705
+ The two agreed today, which is exactly why this was invisible — but a
706
+ consumer registering a key would have grown a control from the registry that
707
+ the store then resolved back to `pop`: the button said "shake", the card
708
+ popped, and nothing reported the gap. Its `modalAnim` now reads
709
+ `window.ModalAnimations` at CALL time with the same late-binding guard the
710
+ shared host uses (unknown keys and a gutted registry still fall back to
711
+ `pop`, so a miss can never throw on `.ms` and strand a modal open); the local
712
+ table remains only as the fallback for a guide rendered without the host.
713
+ MEASURED in a browser both ways: registering a new key at runtime surfaces a
714
+ control AND plays it, and with this fix reverted the same key surfaces a
715
+ control that plays `modal-card-mount`.
474
716
 
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.
717
+ ## 0.69.5 2026-09-06
485
718
 
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.
719
+ ### Fixed
496
720
 
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.
502
-
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.
721
+ - **Onboarding no longer truncates a surname to fit a first name's cap.**
722
+ `Studio::OnboardingController#first_name` measured the WHOLE typed answer with
723
+ `Studio::FIRST_NAME_MAX_LENGTH` — the PER-FIELD cap, 40. The field asks for a
724
+ name and people type a full one, so "Bartholomew Fitzwilliam
725
+ Montgomery-Smythe" (41 characters) stored 40 of them and handed the account
726
+ back its own surname misspelled, **"Montgomery-Smyth"**, on every surface
727
+ `name` feeds.
673
728
 
674
729
  **ONE CONSTANT WAS ANSWERING TWO QUESTIONS**, which is why neither call site
675
730
  looked wrong. `/profile` applies the same number PER FIELD (`normalized_name`,
@@ -714,6 +769,10 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
714
769
  a row whose halves disagree with its `name` because of a `/profile` edit must
715
770
  never be auto-repaired anyway.
716
771
 
772
+ ## 0.69.4 — 2026-09-05
773
+
774
+ ### Fixed
775
+
717
776
  - **Onboarding no longer stores a two-word answer as the whole first name.**
718
777
  `Studio::OnboardingController#first_name` writes with `update_columns`, which
719
778
  skips callbacks — so the host's `before_save :set_name_parts` never ran and
@@ -748,6 +807,69 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
748
807
  and a fossil repair belongs to the consuming app that owns its `users` table,
749
808
  not to the gem.
750
809
 
810
+ ## 0.69.1 — 2026-09-02
811
+
812
+ ### Changed
813
+
814
+ - **`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.
815
+
816
+ - **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.
817
+
818
+ - **`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.
819
+
820
+ ## 0.69.0 — 2026-09-02
821
+
822
+ ### Added
823
+
824
+ - **Knowledge coverage view** — `/admin/knowledge/coverage` +
825
+ `Studio::KnowledgeExpectation`: the "what SHOULD exist" half of the
826
+ knowledge layer. Expectations carry an entity, folder, provenance
827
+ (`source_note`, e.g. "diligence tracker item 14"), and a cadence — `once`
828
+ (an LOI) or `monthly` (aging inventory), where each calendar month from
829
+ `start_on` is a slot and the view names missing months outright
830
+ (`2026-09 MISSING`). Documents fulfill expectations by an EXPLICIT
831
+ `expectation_id` set at triage (the show page grows a "fulfills expectation"
832
+ select; a fuzzy name-match would silently merge lookalikes, an id never
833
+ does), and superseded documents never fill a slot. Ships two reference
834
+ migrations (`create_studio_knowledge_expectations`,
835
+ `add_expectation_to_studio_knowledge_docs`); routes ride the existing
836
+ `Studio.draw_knowledge_routes` opt-in. Built for the Commercial Welding
837
+ 65-item diligence tracker; app-agnostic like the rest of the layer.
838
+
839
+ ## 0.68.0 — 2026-09-01
840
+
841
+ ### Changed
842
+
843
+ - **The auth modal's Solana button moved to solana-studio, behind a CREDENTIAL
844
+ SLOT.** `style/modals/_auth` no longer draws the button; it renders whatever
845
+ resolves at `solana_studio/auth/wallet_credential` and nothing when that path
846
+ is empty, so bundling the gem IS the registration and a web2 app carries no
847
+ wallet markup at all. This completes the two-template split begun in 0.67.0,
848
+ which moved the four web3 MODALS to the gem and left the sign-in button as the
849
+ last web3 markup in the base engine. The gem half has shipped since
850
+ solana-studio **0.5.2** — until now this engine referenced that partial
851
+ nowhere, so the gem shipped a button no host rendered.
852
+ **Supersedes the stranded PR #245**, whose consumer lane was red only because
853
+ it opened before solana-studio 0.5.2 published.
854
+
855
+ **What did NOT change, deliberately:** `_methodDefaults.wallet` is still
856
+ `Studio.auth_method?(:wallet) && Studio.feature?(:web3)`. The Ruby gate
857
+ answers "is it implemented" (is the picker registered, does the credential
858
+ partial resolve) and gates the RENDER; Alpine's `methodOn('wallet')` still
859
+ answers "should it show" and gates VISIBILITY, which keeps the style guide's
860
+ method toggles working. Folding policy into the Ruby gate would delete the
861
+ button from the DOM on a web3-off app that bundles the gem, and the "or"
862
+ divider — which reads `methodOn('wallet')` too — would then float above a
863
+ button that is not there.
864
+
865
+ Hosts need no change: the engine's auth modal is a style-guide specimen
866
+ reached through `/admin/style`, and an app that bundles solana-studio 0.5.2 or
867
+ later sees the same button in the same place.
868
+
869
+ ## 0.67.2 — 2026-09-01
870
+
871
+ ### Fixed
872
+
751
873
  - **Knowledge layer hardening** — the five findings from the 0.67.0 reviews:
752
874
  upload keys carry a random suffix (same-named uploads in the same second no
753
875
  longer overwrite the first object before the unique index can refuse);
@@ -761,6 +883,8 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
761
883
  noted in the migration header (consumer copies are linted by the consumer's
762
884
  rubocop — measured on the Industries adoption ship).
763
885
 
886
+ ## 0.67.0 — 2026-09-01
887
+
764
888
  ### Added
765
889
 
766
890
  - **Knowledge layer primitive** — `Studio::KnowledgeDoc` + `/admin/knowledge`:
@@ -787,6 +911,62 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
787
911
  Deferred to a later cut, recorded on the task: coverage view against a
788
912
  diligence tracker, recurring-series tracking, per-folder access defaults.
789
913
 
914
+ ## 0.66.0 — 2026-08-31
915
+
916
+ ### Changed
917
+
918
+ - **The base template is web2: `:wallet` has left the `auth_methods` default.**
919
+ `Studio.auth_methods` defaulted to `%i[magic_link google wallet]` while
920
+ `Studio.features` defaulted to `[]`. Those two defaults disagree, and the
921
+ disagreement was load-bearing: the auth modal renders its wallet button from
922
+ `auth_method?(:wallet) && feature?(:web3)`, so a stock app rendered **no**
923
+ wallet button — while `Studio.routes` draws the Solana trio from
924
+ `auth_method?(:wallet)` alone, so the same stock app published
925
+ `GET /auth/solana/nonce`, `POST /auth/solana/verify` and
926
+ `GET /auth/phantom/callback`. All three `skip_before_action
927
+ :require_authentication`, and `#verify` lands in `User.from_solana_wallet`,
928
+ which `validate_user_contract!` never requires a host to implement. A brand-new
929
+ newsletter app inherited three public endpoints into an unvalidated contract.
930
+
931
+ studio-engine + McRitchie Studio is the base template for **every** app, web2
932
+ and web3 alike; solana-studio + Turf Monster is the web3 bolt-on. Most apps are
933
+ web2, so the default now says so: `%i[magic_link google]`. `README.md` and
934
+ `docs/NEW_APP_SETUP.md` have always printed that exact line as the new-app
935
+ configuration — the code simply disagreed with the docs it shipped.
936
+
937
+ **No current consumer changes behaviour**, because all three declare
938
+ `auth_methods` explicitly: McRitchie Studio (`magic_link google wallet`),
939
+ Turf Monster (`magic_link google wallet`, and `draw_auth_routes = false`
940
+ besides, so the engine draws neither group for it), mcritchie-industries
941
+ (`magic_link`). A wallet app opts in to both knobs — `auth_methods` including
942
+ `:wallet` **and** `features` including `:web3`, the second being what makes the
943
+ button appear.
944
+
945
+ - **The auth route gate is pinned, and the gate choice is written down.**
946
+ Nothing tested which auth routes `Studio.routes` draws, on an engine whose
947
+ route changes break consumers at **boot** rather than at test time.
948
+ `test/integration/auth_route_gating_test.rb` draws the real host route table
949
+ under each consumer's configuration and reads the drawn routes back, so a
950
+ comment naming `auth/solana/nonce` cannot satisfy it.
951
+
952
+ The gate stays `draw_auth_routes && auth_method?(:wallet)` — **not** `&&
953
+ feature?(:web3)`, even though the modal uses both. `auth_methods` says which
954
+ CREDENTIALS an app accepts and these three paths are the credential exchange
955
+ itself; `features` gates product surfaces. `phantom_callback` is the mobile
956
+ deep-link RETURN url, so an app that declared `:wallet` and forgot `:web3`
957
+ would dead-end the handshake inside the user's wallet app with no server-side
958
+ trace — a worse failure than an endpoint that draws while the UI hides its
959
+ button. That decision is now a test with its rationale attached, so adding the
960
+ feature check flips a red assertion instead of silently unpublishing a live
961
+ consumer's routes. `Studio.draw_auth_routes`' comment likewise now records that
962
+ it is the OUTER switch, not the only gate: each group carries its own
963
+ `auth_methods` sub-gate, which is how a web2 app keeps magic-link while drawing
964
+ no `/auth/solana/*`.
965
+
966
+ ## 0.65.3 — 2026-08-31
967
+
968
+ ### Added
969
+
790
970
  - **A green comment-leak scan used to mean "not looked at".**
791
971
  `test/views/erb_comment_leak_test.rb` guards the ERB comment form in
792
972
  `app/views/**/*.erb`. It never looked inside `<script>`, and that is where this
@@ -808,22 +988,81 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
808
988
  line in an HTML comment does not help — ERB runs inside those too. Describing the
809
989
  tag in words stays legal, exactly as it is for ERB comments.
810
990
 
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.
991
+ **Finding a comment is the hard half, and the first cut of this got it wrong.**
992
+ Anchoring `<script>` blocks on raw source let an ERB comment's *prose mention* of
993
+ a script tag open the block: `studio/_board_assets.html.erb` reported 19_512
994
+ bytes of markup as JavaScript and never saw its real program on its own terms.
995
+ Blocks are now anchored on a copy with ERB tags and HTML comments blanked out,
996
+ length preserved so offsets and line numbers still land. Inside a block the
997
+ walker tracks the states that can hide a comment opener — a string
998
+ (`"https://…"` is not a comment), a template literal, a regex (`/\/\//`) — and a
999
+ literal or comment that never closes is REPORTED rather than absorbed, because a
1000
+ walker that quietly lost its place is the same green as a clean file. The
1001
+ assertions check the input as well as the verdict: how many blocks, comments and
1002
+ bytes the scan actually reached, and that every block it opened matches a real
1003
+ closing tag.
1004
+
1005
+ Zero findings across the engine at `origin/accepted`, so this lands green with no
1006
+ allowlist.
1007
+
1008
+ ## 0.65.2 — 2026-08-28
1009
+
1010
+ ### Fixed
1011
+
1012
+ - **A toast with buttons could not be dismissed while a modal was open.** The
1013
+ layer scale put `--z-banner` (500) above `--z-toast` (400), and `body.modal-open`
1014
+ lifts the environment bar stack to that tier at `position: sticky; top: 0`.
1015
+ `#toast-container` is fixed at top 0 with `1rem` of padding, so the two land on
1016
+ the same pixels: measured in a browser, the bars own y0-47 while the toast's
1017
+ Dismiss button runs y28-40 — an 8-14px overlap at every viewport tested.
1018
+ `elementFromPoint` at that button returned the **banner**, and a real click left
1019
+ the toast on the page. Because the toast manager gives any toast carrying
1020
+ buttons `duration: 0`, that X was the toast's **only** exit: it was stuck for
1021
+ the rest of the session.
1022
+
1023
+ **The tiers are reordered** — `--z-banner` 400, `--z-toast-blur` 499, `--z-toast`
1024
+ 500 — and that is a semantic decision rather than a nudge around the geometry: a
1025
+ toast is transient and demands interaction, a banner is persistent chrome that
1026
+ will still be there afterwards. `--z-banner` still clears `--z-modal` (200), so
1027
+ the property the lift exists for — DEV MODE and the email chip stay lit and
1028
+ clickable over a modal — is unchanged and now has its own spec.
1029
+
1030
+ **One measured cost.** A banner button's tooltip is a descendant of the bar
1031
+ stack, and the modal-open lift makes that stack a stacking context, so the
1032
+ tooltip composites at `--z-banner` rather than at its own `--z-tooltip` (600).
1033
+ At 1440x900 the tooltip and the toast card never meet; at 390x844 they overlap
1034
+ by 260x45px, so a banner tooltip opened with both a modal and a toast up is
1035
+ drawn under the toast. Hover/focus-only, purely visual, and recorded beside the
1036
+ scale.
1037
+
1038
+ **Consumers that redefine these tokens in their own `:root` after the engine
1039
+ import still win**, as they always have, so an app carrying a local copy of the
1040
+ scale keeps the old order (and the bug) until it drops the copy.
1041
+
1042
+ Two literals moved with the tiers: `#toast-container` and `.toast-page-blur` in
1043
+ `layouts/studio/_flash` carry `var(--studio-toast-z, var(--z-toast, …))`
1044
+ fallbacks, and a fallback that disagreed with the scale would hand the bug to
1045
+ any app rendering the partial without the engine sheet.
1046
+
1047
+ **The existing test could not have caught this and still cannot on its own.**
1048
+ `layer_scale_contract_test` asserted `--z-toast > --z-modal`, which was true the
1049
+ entire time the toast was unusable — both tiers cleared the modal, which says
1050
+ nothing about which of *them* wins. It now names the banner, and pins the halo
1051
+ directly beneath its own toast so no tier can settle between them. The property
1052
+ is also asserted where the defect lives, in `e2e/toast_over_banner.spec.js`: a
1053
+ hit test and a real mouse click at the Dismiss button, at desktop and phone
1054
+ widths, at scroll-top and at an offset. And once more on the artifact a
1055
+ consumer is actually served — `test/integration/layer_scale_build_test.rb`
1056
+ runs the real Tailwind binary over the engine's entry point and reads the
1057
+ tiers out of the COMPILED bundle, where `@import` resolution, layer ordering
1058
+ and a shadowing `:root` are all in play and a source read sees none of them. One trap is recorded there — at 390px
1059
+ the point under that button is the banner's Email link, so on the broken build a
1060
+ click *navigated* and the toast count on the new page was zero; asserting the
1061
+ count alone passes over the bug.
1062
+
1063
+ ## 0.65.0 — 2026-08-28
824
1064
 
825
- Zero findings across the engine at `origin/accepted`, so this lands green with no
826
- allowlist.
1065
+ ### Added
827
1066
 
828
1067
  - **`data-pin` — the pinned stack publishes itself.** `--nav-h` / `--nav-bottom`
829
1068
  answer for one element. Everything else that pins has been re-deriving the
@@ -885,56 +1124,28 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
885
1124
  read them, and `nav_offset_contract_test` still pins both to their exact
886
1125
  sources. This is additive.
887
1126
 
1127
+ ## 0.63.0 — 2026-08-27
888
1128
 
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.
1129
+ ### Added
909
1130
 
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.
1131
+ - **`studio/modals/_wallet_connect` the Connect Wallet picker, engine-owned.**
1132
+ The reown-style wallet chooser existed three times before this — turf-monster's
1133
+ 226 lines, mcritchie-studio's 107 and the style guide's 176 — sharing no code.
1134
+ It is one partial now, and `style/modals/_wallet_connect` CONFIGURES it rather
1135
+ than porting it, so the specimen and production cannot drift again. Locals:
1136
+ `store` (default `"modals"`), `connect_fn` (default `"solanaConnectAndVerify"`),
1137
+ `title`, `extra_data` (extra x-data members as a brace-less JS fragment), plus a
1138
+ BLOCK for the pre-connect slot. App behaviour arrives as optional hook METHODS
1139
+ defined in `extra_data`, each called only if it exists: `onInit`, `canPick`,
1140
+ `verifyArgs`, `onConnected(result)`, `onDeepLink`, `onBack`. Carries the mobile
1141
+ Phantom single-row fix (one Phantom row in every state) and a `role="alert"` on
1142
+ the connect error that both app copies lacked.
917
1143
 
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.
1144
+ **NO SHIPPING CONSUMER RENDERS IT YET.** Both apps still ship their own copy and
1145
+ adopt this one after it releases, so the green consumer CI here says they still
1146
+ compile it says nothing about this partial.
924
1147
 
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/*`.
1148
+ ### Changed
938
1149
 
939
1150
  - **The navbar collapse primitive is broadcastable, rate-limited, and cheap per
940
1151
  frame.** Three defects in the primitive as first landed, none of which a
@@ -985,60 +1196,103 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
985
1196
  so the source-swap mutation it was written for still dies, and it gained a
986
1197
  guard on the read-before-write order.
987
1198
 
1199
+ ## 0.62.6 — 2026-08-27
1200
+
1201
+ ### Added
1202
+
1203
+ - **`--nav-p` and `navCollapse()` — the navbar collapse becomes a primitive.**
1204
+ `layouts/studio/_head` now publishes a third header property beside `--nav-h`
1205
+ and `--nav-bottom`. The first two are measurements OF the header; `--nav-p` is
1206
+ an input TO it: collapse progress, `0` expanded to `1` collapsed, written on
1207
+ the `<header>` once per animation frame from `window.scrollY`, and registered
1208
+ with `@property` as a `<number>` so it is legal inside `calc()`, real before
1209
+ the first scroll, and interpolable.
1210
+
1211
+ **It ships no sizing opinion.** A host adopts it by putting `nav-shell` and
1212
+ `x-data="navCollapse()"` on its header, giving each breakpoint band a
1213
+ `--nav-ramp`, and writing its collapsing dimensions as `calc()`s off
1214
+ `--nav-p`. An app whose navbar collapses to different endpoints than this
1215
+ gem's — `mcritchie-studio` goes `w-8 → w-5`, not `w-12 → w-8` — can adopt the
1216
+ mechanism without changing its endpoints.
1217
+
1218
+ **NO SHIPPING CONSUMER EXERCISES THIS YET, and the adoptions are follow-ons.**
1219
+ `turf-monster` overrides `layouts/_navbar.html.erb` AND redefines
1220
+ `window.navCollapse` in `shared/_alpine_factories.html.erb`, rendered after the
1221
+ engine head, so the engine's copy is shadowed. `mcritchie-studio` never renders
1222
+ `layouts/navbar` at all — its inline header still carries `@scroll.window` and
1223
+ `transition-all duration-300`. Said plainly because it also means the green
1224
+ consumer CI on this change is **not evidence about this code path**: both apps
1225
+ compile and pass against a primitive neither of them runs.
988
1226
 
989
1227
  ### Fixed
990
1228
 
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.
1229
+ - **The engine navbar's collapse tracks the finger instead of a clock.**
1230
+ `layouts/_navbar` flipped `scrolled` at `scrollY > 60` and fed that step into
1231
+ `transition-all duration-300` on padding, logo `width`/`height` and two font
1232
+ sizes. The finger set the step; an ease curve owned everything after it.
1233
+ Measured in `turf-monster` at 390×844 on the same code before the change: the
1234
+ header ran 178px 139px and **34 of those 39px of document reflow landed
1235
+ AFTER the scroll had stopped, over 232ms**, at up to 3px per frame — plus a
1236
+ 1px REVERSE lurch in the frame the class flipped, where a discrete
1237
+ `text-3xl → text-xl` swap collided with the stylesheet's own
1238
+ `transition: font-size`.
1001
1239
 
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.
1240
+ Every collapsing dimension is now a `calc()` off `--nav-p`, and nothing on the
1241
+ path carries a time-based transition. **Both endpoints are unchanged at every
1242
+ breakpoint** — this is the same navbar, re-plumbed, so a consumer sees no
1243
+ visual difference at rest.
1008
1244
 
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.
1245
+ Content still accelerates *during* the collapse: a sticky header is in flow,
1246
+ so shrinking it adds to the scroll and content necessarily outruns the finger.
1247
+ That is the point of a collapse, not a defect. What is tunable is the shape,
1248
+ and `--nav-ramp` is the band's collapse total with a smoothstep ease, whose
1249
+ slope is zero at both ends so the burst leaves 1×, peaks near 1.5×, and
1250
+ returns to with no velocity step. A linear ramp equal to the collapse hits
1251
+ 2× and steps straight back.
1016
1252
 
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.
1253
+ Also included: a passive, rAF-coalesced listener that writes to the header
1254
+ rather than `:root` (keeping each frame's style recalc inside the navbar
1255
+ subtree); a short-page guard, because collapsing shortens the document and on
1256
+ a barely-scrollable page that deletes the very scroll room that triggered it,
1257
+ clamps `scrollY` to 0, and flaps forever; and a `prefers-reduced-motion`
1258
+ branch that snaps `--nav-p` to `0`/`1`, since resizing type under a moving
1259
+ finger is itself motion worth dropping.
1020
1260
 
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.
1261
+ `/navbar`'s Scrolled toggle drops **twelve `!important` rules** that restated
1262
+ every collapsed value — it sets `--nav-p: 0|1` and transitions the registered
1263
+ property, so the preview exercises the shipped `calc()`s instead of a parallel
1264
+ copy of them.
1025
1265
 
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.
1266
+ ## 0.61.2 2026-08-26
1267
+
1268
+ ### Fixed
1269
+
1270
+ - **The age gate's back link now returns the date the person already entered.**
1271
+ `blocks/_age_gate`'s "Update your Birthday" swapped back to `blocks/_birthday`
1272
+ with an EMPTY props object, discarding the `dobYear`/`dobMonth`/`dobDay` parts
1273
+ the birthday factory had just handed across the store and the factory started
1274
+ its three fields at `""` and never read them anyway. So the correction path came
1275
+ back BLANK: a mistyped year cost all three picks, on a card whose own header
1276
+ comment promised "a correction, not a restart".
1277
+
1278
+ Both sides of the seam moved. `back()` forwards the three date parts and only
1279
+ those three, deliberately: `minAge`, `state` and `message` describe the refusal,
1280
+ and `validates` is load-bearing by its ABSENCE (the style guide specimen reads an
1281
+ absent prop as validating, and a forwarded stale one would let the refusal pick
1282
+ the next card's mode). `window.birthdayModal` gained an `init` that re-picks the
1283
+ three selects from the props of the modal entry it mounted in, clamping a day
1284
+ that the restored month cannot hold.
1285
+
1286
+ **The gate is not weakened.** Restoring the date restores the DATE and never the
1287
+ verdict: the card comes back submittable and the app's endpoint re-decides on the
1288
+ next submit exactly as it did on the first. `e2e/birthday_gate.spec.js` asserts a
1289
+ restored under-age date is refused again.
1290
+
1291
+ **No consumer action.** A host that renders `blocks/_age_gate` and
1292
+ `studio/_birthday_assets` gets this by upgrading; nothing in the call signature
1293
+ changed and no host wiring moves.
1041
1294
 
1295
+ ## 0.60.1 — 2026-08-25
1042
1296
 
1043
1297
  ### Breaking
1044
1298
 
@@ -1067,38 +1321,14 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
1067
1321
  that pass a number are unaffected. There is still NO engine default, because 18
1068
1322
  is itself a policy value.
1069
1323
 
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.
1324
+ - **An under-age date now SUBMITS.** It used to disable the card's own submit
1325
+ button and turn the card red — the one screen state with nothing to press. The
1326
+ factory routes on the RESPONSE instead: `body.underage === true`, or HTTP 403,
1327
+ opens the age-gate card; anything else non-verified stays on the error line. An
1328
+ app adopting this must make its verify endpoint answer "too young" distinctly
1329
+ from "bad request", because a client that blocks submission never had to.
1330
+
1331
+ ### Added
1102
1332
 
1103
1333
  - **`studio/modals/blocks/_age_gate` — the refusal card.** Where the birthday card
1104
1334
  hands off when the date is under the app's bar. Headed "Easy, Young'un" under a
@@ -1109,61 +1339,54 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
1109
1339
  absent rather than rendered dead) and a back link that returns to the birthday
1110
1340
  card. Displays `min_age` / `state`; computes no eligibility of its own.
1111
1341
 
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.
1342
+ ## 0.59.02026-08-21
1128
1343
 
1129
1344
  ### Fixed
1130
1345
 
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`.
1346
+ - **The Geo signpost now reaches every app whose admin chrome the engine owns.**
1347
+ The row added last release went into `components/_admin_dropdown` alone, on the
1348
+ premise that the shared dropdown reaches every app from one change. It does
1349
+ not, and counted against the apps that actually consume this engine it reached
1350
+ **none of them**. Wherever a host declares an admin-flagged sidebar section,
1351
+ `studio_sidebar_replaces_admin_menu?` SUPPRESSES the dropdown and the link
1352
+ sidebar is the admin menu that is both `mcritchie-studio` and
1353
+ `mcritchie-industries`. An app that forks its navbar outright renders neither,
1354
+ which is `turf-monster`. Nobody was left on the plain engine navbar to see it,
1355
+ so the app carrying the largest admin surface in the ecosystem was among those
1356
+ guaranteed never to.
1141
1357
 
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.
1358
+ The row moved into its own partial, **`components/_geo_signpost`**, and BOTH
1359
+ engine chromes render it the dropdown as before, and
1360
+ `components/_link_sidebar` under its own admin-chipped `Geo` heading. Nothing
1361
+ to configure: a host on either chrome gets the signage from the version bump.
1146
1362
 
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.
1363
+ **Exactly one of them, never both.** The sidebar carries the row only where it
1364
+ IS the admin menu (the host declared an admin-flagged section, so the dropdown
1365
+ is suppressed). A host declaring only public sections renders BOTH chromes by
1366
+ design, and there the dropdown keeps the row fixing a reach gap by showing
1367
+ an admin the same row twice is not a fix.
1154
1368
 
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.
1369
+ **A forked chrome still needs one line**, because the engine cannot reach a
1370
+ fork by rendering. Inside your own admin block:
1162
1371
 
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.
1372
+ ```erb
1373
+ <%= render "components/geo_signpost",
1374
+ variant: :sidebar, close_action: "$store.sidebars.gearOpen = false" %>
1375
+ ```
1376
+
1377
+ `variant:` picks the chrome's row shape (`:dropdown`, the default, or
1378
+ `:sidebar`); `close_action:` is the Alpine expression that closes YOUR panel
1379
+ behind the row — the store flag belongs to the caller, so the partial never
1380
+ hardcodes one. See [`docs/GEO.md`](docs/GEO.md#finding-the-page--the-signpost-in-whichever-chrome-the-app-has).
1381
+
1382
+ Behavior is otherwise unchanged: same three states, same words, same
1383
+ self-gating on `admin?`, and `ENABLE_GEO_BLOCKING` still governs the LINK and
1384
+ never the gate. One test hook was renamed — `data-test="geo-signpost-disabled"`,
1385
+ was `admin-dropdown-geo-disabled` — since the row renders in two chromes now
1386
+ and a name claiming one of them was a lie. Grepped ecosystem-wide first: it
1387
+ appeared in no consumer.
1388
+
1389
+ ## 0.58.0 — 2026-08-21
1167
1390
 
1168
1391
  ### Added
1169
1392
 
@@ -1181,6 +1404,10 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
1181
1404
  enforcement would silently stop a live legal blocklist on the next deploy. An
1182
1405
  app that would rather decide in code sets `config.geo_blocking_enabled`.
1183
1406
 
1407
+ ## 0.57.0 — 2026-08-20
1408
+
1409
+ ### Added
1410
+
1184
1411
  - **Geo — validation for every app, locking for the apps that need it.**
1185
1412
  Turf Monster's geo stack, lifted into the engine and generalised from
1186
1413
  US-states-only to country + subdivision. Full guide:
@@ -1236,6 +1463,51 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
1236
1463
  exist, and a *resolved foreign* country with no region stays allowed. Opt out
1237
1464
  with `Studio.geo_fail_closed = false`.
1238
1465
 
1466
+ ## 0.56.3 — 2026-08-19
1467
+
1468
+ ### Changed
1469
+
1470
+ - **Montserrat is vendored into the engine and can no longer re-measure the page.**
1471
+ `layouts/studio/_head` linked the family from `fonts.googleapis.com` with
1472
+ `display=swap`. The stylesheet blocks the load event; the font FILES do not — so
1473
+ every app went interactive in the fallback font and re-measured every glyph when
1474
+ Montserrat landed. That reflow swallowed a synthesized click three times in one CI
1475
+ day (root-caused in `close-board-filter-flake`: `pointerdown` and `pointerup` hit
1476
+ different elements, so the browser fired `click` on their common ancestor), and on a
1477
+ cold cache it moved a filter chip under a real finger. Montserrat was the last
1478
+ third-party asset in the head; Alpine, SortableJS and canvas-confetti were already
1479
+ vendored.
1480
+
1481
+ What ships:
1482
+
1483
+ - **Two woff2 files** — `studio/montserrat-latin.woff2` (35KB) and
1484
+ `studio/montserrat-latin-ext.woff2` (68KB), served through the asset pipeline and
1485
+ precompiled for Sprockets hosts. The `fonts.googleapis.com` and `fonts.gstatic.com`
1486
+ preconnects are gone, and the head reaches no third party at all.
1487
+ - **`font-display: optional`, not `swap`.** This is the fix, not the self-hosting.
1488
+ `swap` has an unbounded swap period, so a late font always reflows wherever it is
1489
+ served from; `optional` has none, so a font that misses the block period is
1490
+ abandoned for that navigation instead of shifting the layout. The cost is named: a
1491
+ first visit on a slow link can render one page in the fallback. A `crossorigin`
1492
+ preload of the latin subset is what keeps that rare.
1493
+ - **One face per subset spanning `100 900`.** Montserrat v31 is a VARIABLE font — the
1494
+ six weights the old link requested all resolved to the same bytes — so every weight
1495
+ the fleet uses renders from real outlines rather than a synthesized fake, and asking
1496
+ for fewer weights would not have saved a byte.
1497
+ - **Only latin and latin-ext.** cyrillic, cyrillic-ext and vietnamese occur zero times
1498
+ in the engine's views, the consumers' views, and their seed and locale data, so they
1499
+ are not shipped. `unicode-range` means a page only downloads the subsets it renders,
1500
+ so keeping latin-ext (for roster names like Modrić and Çalhanoğlu) costs an English
1501
+ page nothing.
1502
+
1503
+ No consumer action is required: the family, the weights, and the Tailwind `sans` stack
1504
+ are unchanged. An app that sets its own `font-display` or preloads Montserrat itself
1505
+ should drop that.
1506
+
1507
+ ## 0.56.0 — 2026-08-16
1508
+
1509
+ ### Added
1510
+
1239
1511
  - **The hold-to-confirm button, with its fizz — a new ACTION family in
1240
1512
  `engine-motion.css`.** A press-and-hold CTA for an action a host does not want
1241
1513
  taken by accident, ported from Turf Monster where it confirms a contest entry.
@@ -1283,6 +1555,10 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
1283
1555
  page matches the one it replaced) and two buttons on a page do not fizz in
1284
1556
  lockstep.
1285
1557
 
1558
+ ## 0.55.0 — 2026-08-16
1559
+
1560
+ ### Added
1561
+
1286
1562
  - **`.studio-team-glow` rings a host that paints its own background**, and can
1287
1563
  carry **two colors**.
1288
1564
 
@@ -1304,118 +1580,13 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
1304
1580
  defaults to the first, so one-color callers see no change and a caller with a
1305
1581
  pair to show — a Pokémon's two types — gets one per wedge.
1306
1582
 
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.
1583
+ The bloom twin needed care: a mask applies AFTER a filter, so its box carries a
1584
+ transparent border twice the blur radius and its wedges stop at the padding
1585
+ box. Without that room the halo ends on a hard line at its own edge.
1586
+
1587
+ ## 0.54.1 — 2026-08-15
1588
+
1589
+ ### Fixed
1419
1590
 
1420
1591
  - **`.conic-surface` keeps its wash inside its own box.** The wash was an
1421
1592
  OVERSIZED pseudo (`inset: -50%`) spun by a `transform` keyframe, cut back to
@@ -1450,6 +1621,8 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
1450
1621
  contract comment now all state the same thing. The primitive's geometry is
1451
1622
  unchanged — the consumers tuned against it are untouched.
1452
1623
 
1624
+ ## 0.54.0 — 2026-08-15
1625
+
1453
1626
  ### Added
1454
1627
 
1455
1628
  - **`studio/fields/_date_of_birth`** — the engine's one date-of-birth field,
@@ -1485,6 +1658,7 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
1485
1658
  clearing a day the new month lost, refusing a partial date, and a year list
1486
1659
  that stops at this year.
1487
1660
 
1661
+ ## 0.53.1 — 2026-08-15
1488
1662
 
1489
1663
  ### Changed
1490
1664
 
@@ -1502,6 +1676,7 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
1502
1676
  `position: fixed`, placed from the trigger's rect, so it never relied on that
1503
1677
  wrapper as a containing block.
1504
1678
 
1679
+ ## 0.53.0 — 2026-08-15
1505
1680
 
1506
1681
  ### Added
1507
1682
 
@@ -1534,7 +1709,6 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
1534
1709
  Fires on both verbs, so a host that only cares about joins ignores the flag.
1535
1710
  The default is inert.
1536
1711
 
1537
-
1538
1712
  ### Fixed
1539
1713
 
1540
1714
  - **The birthday calendar keeps the side it opened on.** `place()` runs on every
@@ -1552,6 +1726,7 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
1552
1726
  should be re-decided at all. The flip spec added with it opens and asserts
1553
1727
  once, so a side that changes **later** was invisible to it.
1554
1728
 
1729
+ ## 0.52.3 — 2026-08-15
1555
1730
 
1556
1731
  ### Fixed
1557
1732
 
@@ -1575,6 +1750,40 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
1575
1750
  fits. The new spec forces the flip, asserts the flip actually happened, and then
1576
1751
  asserts the popover's bottom edge sits against the trigger's top edge.
1577
1752
 
1753
+ ## 0.52.0 — 2026-08-15
1754
+
1755
+ ### Added
1756
+
1757
+ - **`/profile` gains the Newsletter row.** Lifted from turf-monster's `/account`
1758
+ card, which has run this flow in production, and stripped of everything
1759
+ turf-specific on the way: its 25-seed on-chain welcome bonus, its quest state,
1760
+ its seeds level-up payload. What the engine takes is the part every app needs.
1761
+
1762
+ New routes: `POST /profile/newsletter` (`profile_newsletter_path`) joins,
1763
+ `DELETE` on the same path leaves.
1764
+
1765
+ **TWO TIMESTAMPS, NOT A BOOLEAN.** `joined_email_list_at` and
1766
+ `left_email_list_at`, matching turf, because the pair carries three states a
1767
+ flag cannot: never asked (both nil), subscribed (joined after left, *including
1768
+ a rejoin where both are set*), and unsubscribed. `Studio::Newsletter` holds the
1769
+ rules — `subscribed?`, `ever_joined?`, `needs_email?` — pure and duck-typed
1770
+ like `Studio::OauthIdentity`.
1771
+
1772
+ `ever_joined?` is deliberately a different question from `subscribed?`: leaving
1773
+ stamps a date and never clears the join, so a consumer paying a once-ever
1774
+ welcome bonus cannot have it re-earned by cycling.
1775
+
1776
+ **ASYMMETRIC ON PURPOSE.** Joining is one click; leaving asks for confirmation
1777
+ in a modal. Joining is reversible from the same card, so a confirm step would
1778
+ be friction protecting nothing — a mis-click on leave is silent until the next
1779
+ send that never arrives. An account with **no address on file** (a wallet-only
1780
+ sign-in) is asked for one in a modal rather than allowed to submit and fail;
1781
+ the address is written but **not** marked verified, because typing an address
1782
+ is not the same as holding it.
1783
+
1784
+ Gated on `requires:` like every other row, so a host without the columns gets
1785
+ silence rather than a 500. The columns ship consumer-first under
1786
+ *Roll Out Standard Profile Columns*.
1578
1787
 
1579
1788
  ### Changed
1580
1789
 
@@ -1614,39 +1823,18 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
1614
1823
  The three integer columns are unchanged; the UI joins them for entry and
1615
1824
  `ProfilesController#update` splits them again.
1616
1825
 
1826
+ - **The read page mounts a modal host only when a row asks for one.** This is the
1827
+ registry's `modals:` key finally doing the job it was documented for — until
1828
+ the newsletter row, nothing on `/profile` opened a modal and mounting a host
1829
+ would have been furniture for nobody.
1617
1830
 
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.
1831
+ `modals:` is now **a partial path rather than a boolean**. A host row that
1832
+ declares modals keeps its partial in the host's own app, so any convention like
1833
+ `"studio/profiles/#{key}_modals"` would resolve to a path that does not exist
1834
+ there. It costs one string and works for everyone.
1646
1835
 
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*.
1836
+ The host is **not** the cropper: `/profile` mounts `studio/modals/_scoped_host`
1837
+ and never `studio/cropper_assets`, because the avatar is read-only on that page.
1650
1838
 
1651
1839
  ### Fixed
1652
1840
 
@@ -1668,21 +1856,7 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
1668
1856
  first browser spec — the view suite was green on the `@click` attribute
1669
1857
  throughout.
1670
1858
 
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
-
1859
+ ## 0.50.0 — 2026-08-15
1686
1860
 
1687
1861
  ### Added
1688
1862
 
@@ -1719,19 +1893,6 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
1719
1893
  the intended consequence — the sidebar always has at least the viewer's own
1720
1894
  profile in it — but it is a visible change to a navbar every consumer inherits.
1721
1895
 
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
1896
  - **`/profile` splits into a read page and an edit page.** `/profile` is now "you
1736
1897
  at a glance" — the identity header, and the rows you look at rather than type
1737
1898
  into. `/profile/edit` (`edit_profile_path`) holds the fields, in ONE form with
@@ -1760,6 +1921,10 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
1760
1921
  adding a second copy for a screen reader to read out. Honors
1761
1922
  `prefers-reduced-motion`.
1762
1923
 
1924
+ ## 0.49.0 — 2026-08-15
1925
+
1926
+ ### Added
1927
+
1763
1928
  - **`/profile` gains the Email field — changeable from any signed-in session.**
1764
1929
  It saves with the rest of the edit form through `PATCH /profile`.
1765
1930
 
@@ -1791,6 +1956,10 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
1791
1956
  not raise, it would quietly send TURF's copy, banner and `account_url` instead
1792
1957
  of the engine's. The silent case is the dangerous one.
1793
1958
 
1959
+ ## 0.48.0 — 2026-08-15
1960
+
1961
+ ### Added
1962
+
1794
1963
  - **`/profile` gains the Google account row.** Shows the linked identity with an
1795
1964
  Unlink control, or a branded Connect button that POSTs to OmniAuth's own
1796
1965
  `/auth/google_oauth2` (the engine does not draw a link route — the middleware
@@ -1984,6 +2153,21 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
1984
2153
  mcritchie-industries and acquisition-studio gain a working link where they had
1985
2154
  `href="#"`; every other consumer forks the partial and is untouched.
1986
2155
 
2156
+ ### Fixed
2157
+
2158
+ - **`Studio.profile_sections`' `if:` gate no longer fails open on a Symbol.**
2159
+ `if: :some_predicate` — Rails' own `before_action ..., if: :method_name`
2160
+ spelling, and so the most natural thing a host will write — was coerced
2161
+ straight to `true`, because a Symbol does not answer `call`. The gate silently
2162
+ did nothing, with no signal to the host, in the same permissive direction as
2163
+ the bug `if:` was added to fix. Symbols and Strings now name a method on the
2164
+ view; a name the view does not answer drops the row rather than rendering it.
2165
+ Lambdas and plain booleans are unchanged.
2166
+
2167
+ ## 0.47.2 — 2026-08-14
2168
+
2169
+ ### Changed
2170
+
1987
2171
  - **No pre-registered email seeds a logo — `magic_link` was the last one, and it
1988
2172
  seeded the Studio wordmark onto the SIGN-IN email.** `STANDARD`'s `magic_link`
1989
2173
  entry carried `logo: "emails/logo-horizontal.png"`, and
@@ -2033,32 +2217,7 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
2033
2217
  that registers no mark, so the page says so rather than offering a choice that
2034
2218
  changes nothing.
2035
2219
 
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.
2220
+ ## 0.47.0 2026-08-13
2062
2221
 
2063
2222
  ### Added
2064
2223
 
@@ -2116,6 +2275,10 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
2116
2275
  exact defect `isLive` exists to prevent. A turf-side follow-up, not part of
2117
2276
  this change.
2118
2277
 
2278
+ ## 0.46.0 — 2026-08-13
2279
+
2280
+ ### Added
2281
+
2119
2282
  - **The standard user profile columns — and the engine's first migration against
2120
2283
  a host-owned table.** Every other engine migration creates a `studio_*` table
2121
2284
  the engine owns outright; `users` belongs to the host. That boundary is crossed
@@ -2193,6 +2356,46 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
2193
2356
  rather than raising on every signed-in request — so the gem can land there
2194
2357
  before the migration does.
2195
2358
 
2359
+ ## 0.44.1 — 2026-08-13
2360
+
2361
+ ### Fixed
2362
+
2363
+ - **The newsletter's seed lent every host the McRitchie Studio wordmark.**
2364
+ `STANDARD`'s `newsletter_subscribed` entry seeded
2365
+ `logo: "emails/logo-horizontal.png"`, and `EmailCatalog.register` merges on
2366
+ `logo.presence` — so an omitted `logo:` INHERITS. A host registering that email
2367
+ for any ordinary reason (a relabel, its own preview builder) would have sent its
2368
+ branded newsletter under Studio's mark, and the alt text could not warn anyone:
2369
+ `Studio::Banner` sets it from `Studio.app_name`, so it always agrees with the
2370
+ host and never with the pixels. turf-monster hit the same mechanism on
2371
+ `magic_link`.
2372
+
2373
+ **No consumer was exposed.** No app registers `newsletter_subscribed` or sends
2374
+ it — turf's own `NewsletterMailer` sends its flat `newsletter_welcome` — so this
2375
+ was visible only on the `/admin/emails` preview. It was seeded identically in
2376
+ 0.42 and 0.43; nothing changed it recently.
2377
+
2378
+ The seed now carries no logo. **Artwork still rides the gem** — a background is
2379
+ a picture any app can send, and inheriting one is what gives a new app
2380
+ good-looking mail on day one. A wordmark is somebody's identity, and the line is
2381
+ drawn there. An app that wants a mark registers a path IT ships
2382
+ (`register("newsletter_subscribed", logo: "emails/our-mark.png")`) or sets one
2383
+ on /admin/emails. Guards render the mail and resolve every `<img>` back to a
2384
+ file on the engine's own asset load path, comparing the wordmark by its BYTES,
2385
+ so renaming the file and re-seeding it is caught too.
2386
+
2387
+ **`magic_link` is unchanged and still seeds the wordmark.** A host that
2388
+ registers no mark of its own inherits it on the sign-in email. That is an
2389
+ **upgrade trap** rather than a live leak — the apps with no email initializer
2390
+ are pinned before this registry existed, so they inherit nothing today, but the
2391
+ bump that brings one forward would hand it another brand's mark with no code
2392
+ change of its own. Tracked separately, with the design question of whether
2393
+ `STANDARD` should seed a logo at all.
2394
+
2395
+ ## 0.42.0 — 2026-08-12
2396
+
2397
+ ### Added
2398
+
2196
2399
  - **`Studio::NewsletterMailer` — a sendable "you're on the list" email.**
2197
2400
  Namespaced under `Studio::` on purpose: a host that defines its own top-level
2198
2401
  `UserMailer` (McRitchie Studio does) SHADOWS the engine's outright, so an
@@ -2245,39 +2448,36 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
2245
2448
  host's own users with avatar and initials, degrading to a sample when an app has
2246
2449
  none.
2247
2450
 
2248
- ### Fixed
2451
+ ### Changed
2249
2452
 
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`.
2453
+ - **`email_change_confirmation` leaves the pre-registered set; `newsletter_subscribed`
2454
+ joins it.** `STANDARD` means "the emails EVERY Studio app sends", and only
2455
+ turf-monster sends an email-change confirmation which it **already registers
2456
+ itself**, with its own artwork, in `config/initializers/studio_emails.rb`. No
2457
+ host loses a banner, and no host action is required for the removal.
2259
2458
 
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.
2459
+ What DOES break consumers is the ADDITION. Three suites assert against the
2460
+ whole shared catalogue, so a new standard email fails them:
2264
2461
 
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.
2462
+ | Consumer | Assertion | Fix | State |
2463
+ |---|---|---|---|
2464
+ | turf-monster | `EmailRegistrationTest` compares the full key list | McRitchie-Studio/turf-monster#292 | merged |
2465
+ | turf-monster | `AdminEmailsRenderTest` counts `tbody img` exactly | McRitchie-Studio/turf-monster#294 | merged |
2466
+ | mcritchie-studio | `StudioEmailsPageTest` pins the registry list, an `email_change_confirmation` link, and the upload-rejection wording | McRitchie-Studio/mcritchie-studio#813 | merged |
2273
2467
 
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.
2468
+ The second turf fix is the easy one to miss: a layered standard email renders
2469
+ its banner through an `<iframe>` rather than an `<img>`, so an exact image
2470
+ count comes up one short. #292 alone does NOT turn that lane green.
2471
+
2472
+ Consumer CI builds each app against its **default branch**, so all three had to
2473
+ reach `main` before this engine's consumer lanes could go green. All three now
2474
+ have, and the lanes pass. Nothing on this branch ever could have turned them
2475
+ green — a red consumer lane here is a consumer-side fix, not an engine defect.
2476
+
2477
+ The artwork `emails/email-change-confirmation.gif` still ships, so a host that
2478
+ wants the entry can register it.
2479
+
2480
+ ### Fixed
2281
2481
 
2282
2482
  - **The manager reported artwork that was not being sent.** A layered-native email
2283
2483
  (no flat `default_asset`) drew an empty box and a "sends without a banner" badge
@@ -2291,6 +2491,8 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
2291
2491
  existing guard was real and only ran on the index, so it never saw the page that
2292
2492
  shipped it.
2293
2493
 
2494
+ ## 0.41.0 — 2026-08-12
2495
+
2294
2496
  ### Added
2295
2497
 
2296
2498
  - **A browser lane — the engine now runs Playwright against its own partials.**
@@ -2312,6 +2514,10 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
2312
2514
  and covered instead by `test/views/at_time_zone_table_test.rb`. Full rationale,
2313
2515
  limits and cost: `docs/E2E_LANE.md`.
2314
2516
 
2517
+ ## 0.40.0 — 2026-08-12
2518
+
2519
+ ### Added
2520
+
2315
2521
  - **The "at" time stamp — `at_time_tag` + `studio/at_time_script`.** A shared
2316
2522
  primitive for stamping WHEN something happened, on the READER's clock rather
2317
2523
  than the app's: `at 3:53p`, gaining a date only when the stamp is not today and