studio-engine 0.74.8 → 0.74.10

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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +133 -3
  3. data/README.md +130 -74
  4. data/app/assets/tailwind/studio_engine/engine-motion.css +51 -3
  5. data/app/views/components/_avatar_cropper.html.erb +1 -1
  6. data/app/views/error_logs/index.html.erb +1 -1
  7. data/app/views/schema/index.html.erb +2 -2
  8. data/app/views/sessions/new.html.erb +1 -1
  9. data/app/views/solana_sessions/phantom_callback.html.erb +39 -1
  10. data/app/views/studio/email_images/index.html.erb +1 -1
  11. data/app/views/studio/emails/index.html.erb +4 -3
  12. data/app/views/studio/emails/orphan.html.erb +1 -1
  13. data/app/views/studio/emails/show.html.erb +8 -7
  14. data/app/views/studio/modals/_crop_photo.html.erb +3 -2
  15. data/app/views/studio/modals/_host.html.erb +5 -1
  16. data/app/views/studio/modals/_scoped_host.html.erb +13 -11
  17. data/app/views/studio/modals/auth/_resend_footer.html.erb +26 -2
  18. data/app/views/studio/modals/blocks/_age_gate.html.erb +8 -4
  19. data/app/views/studio/modals/blocks/_birthday.html.erb +1 -1
  20. data/app/views/studio/modals/blocks/_entry_confirmed.html.erb +2 -1
  21. data/app/views/studio/modals/blocks/_leveling_activity.html.erb +1 -1
  22. data/app/views/studio/modals/onboarding/_first_name.html.erb +9 -8
  23. data/app/views/style/modals/_newsletter_email.html.erb +2 -1
  24. data/app/views/style/modals/_unsubscribe_confirm.html.erb +2 -1
  25. data/lib/studio/color_scale.rb +6 -0
  26. data/lib/studio/js_identifier.rb +3 -2
  27. data/lib/studio/js_literal.rb +3 -1
  28. data/lib/studio/theme_resolver.rb +47 -6
  29. data/lib/studio/version.rb +1 -1
  30. data/tailwind/studio.tailwind.config.js +22 -5
  31. metadata +2 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c8073dcfcec3579f3a3fa810e5b851ac3eb38a99e38bb184183b4d6a13d8da6e
4
- data.tar.gz: 3f7bc2747c4101c7785b3f3c22b673f80467e600b88af943900d1c3a4c79fdbd
3
+ metadata.gz: 91281f58c1fc5ccba013b728eaaa3fb8b8e1fc40b451e45893b832c83fc02151
4
+ data.tar.gz: 39ffd06a4758929bbd1af758d17b631596f3b9a4e8cf8df8b54c67c49a0bb8ee
5
5
  SHA512:
6
- metadata.gz: d57ba1e14f875f76dd2cc2e5d75c5db00decb0f5235222dfabec4501c9ae3f6b821720188f6c84aca67b68d3b2abb99d0a9022eaa8da205955820029d2e9a19b
7
- data.tar.gz: 1165eedf5e6bce8f56d60c83f0cdf96cd87bdd0c297c48906c88f782d20bbb8af102913272cf3663b73972cfd6d76474452e6900489ab2035dfee98d777673ca
6
+ metadata.gz: 7140c3b5cc09362d78f88924764c6fb8954727e65cc584a25cbaaf58496fc8b50703ebd08146b5b20cb353689150dd0704b9920ca06541025142f8b039f6f8d0
7
+ data.tar.gz: cae0907183d7e649afbcb450bfcaed6223e2b376337ef0eda86518169c695474b6416dfde78a9fdba0bece899014f16954916560a0dcb431c50a93a67727a963
data/CHANGELOG.md CHANGED
@@ -1,11 +1,120 @@
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)). `prepare` does NOT roll this file: renaming `## Unreleased` to the allocated version and opening a fresh empty one is a manual conductor step (see [`docs/RELEASE.md`](./docs/RELEASE.md), *Rolling `Unreleased` into a version*), guarded by `test/docs/changelog_structure_test.rb`.
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
+ ## 0.74.10 — 2026-09-13
8
+
9
+ ### Fixed
10
+
11
+ - **The auth resend footer's error line failed WCAG AA in both themes; it now
12
+ uses the danger ink.** `studio/modals/auth/_resend_footer` painted its
13
+ `role="alert"` paragraph with the static `text-red-400`, which Tailwind v4
14
+ compiles to about `#FF6467`: 3.86:1 on the dark modal card and 2.89:1 on the
15
+ light one, both under the 4.5:1 that 12px text needs. It is now
16
+ `text-danger-ink`, the red `Studio::ThemeResolver` derives per theme to clear
17
+ 4.5:1 on every surface: 4.50:1 dark and 5.76:1 light on the default modal
18
+ card. The resend spinner stays the engine's `.spinner`, tuned to
19
+ turf-monster's currentColor ring (`--spinner-track: currentColor;
20
+ --spinner-color: transparent`), so a host that drops its fork sees no change.
21
+ Guarded by `test/views/resend_footer_error_contrast_test.rb`.
22
+ - **The sign-in overlay blurs in every consumer, and the eight engine classes
23
+ that painted nothing are fixed.** The class vocabulary guard
24
+ (`test/views/engine_class_vocabulary_test.rb`) listed them as open defects;
25
+ its allow-list now holds only deliberate JS and test hooks.
26
+ - `sessions/new`'s SSO overlay used turf-monster's `backdrop-overlay`, so
27
+ mcritchie-industries showed it with no blur and no scrim. It now carries
28
+ `backdrop-blur-[2px] backdrop-brightness-70 bg-primary-900/20`, which
29
+ compiles to turf's `blur(2px) brightness(0.7)` over a 20% primary-900.
30
+ Guarded by `test/integration/sign_in_overlay_blur_test.rb`, which renders
31
+ the page and compiles the overlay's classes without the motion layer.
32
+ - `_email_field`'s `email-reject` shake lived only in turf. The engine's
33
+ motion layer now owns it under the same name, with byte-compatible
34
+ keyframes and a reduced-motion variant that keeps the border flash.
35
+ - `text-danger`, `hover:text-danger`, `text-warning`, `bg-warning/10`,
36
+ `border-warning/30` and `placeholder-muted` compiled to nothing. The
37
+ text forms are now `text-danger-ink` and `text-warning-ink`, the fills
38
+ compile, and `placeholder-muted` is `placeholder:text-muted`. The same
39
+ fix reaches the success and warning badges in `studio/emails/show` and
40
+ the flash panel in `studio/email_images/index`, whose classes sit in Ruby
41
+ strings the guard's scanner cannot read; a raw-source check now covers
42
+ them. The flash panel is now `bg-surface` with a role border, because
43
+ danger-ink on a danger tint measures 4.10 to 4.18:1, under AA.
44
+ - **For consumers:** turf-monster can delete its `@utility backdrop-overlay`
45
+ (no turf view uses it now) and its `@utility email-reject` plus keyframes.
46
+ Until it does, turf's utilities-layer copy outranks the engine's rule, so
47
+ turf keeps its own shake and does not get the reduced-motion variant.
48
+ - **The wallet callback page no longer names one vendor, and a host intent can
49
+ now narrate the wait.** `solana_sessions/phantom_callback` opened on
50
+ "Processing Phantom response..." and changed that line only on the legacy
51
+ sign-in path. So a transaction resumed through `walletOps` showed one sentence
52
+ for the whole cosign, broadcast and confirm leg: 18 seconds measured on a QA
53
+ iPhone for a contest entry. That sentence was also wrong for Solflare and
54
+ Backpack, which the redirect transport serves too. The page now opens on
55
+ "Processing your wallet's response...". Before it resumes, it listens for a
56
+ `studio:wallet-progress` event and writes `detail.text` into the status line
57
+ as text. A push with no text is ignored, so the default stays until something
58
+ real replaces it. The engine still knows nothing of what the user was doing.
59
+ Guarded by `test/views/phantom_callback_test.rb` and
60
+ `test/views/phantom_callback_resume_test.rb`.
61
+ - **For consumers:** no host edit is needed to get the neutral copy. To
62
+ narrate, dispatch the event from the intent's `complete()`:
63
+ `document.dispatchEvent(new CustomEvent('studio:wallet-progress', { detail:
64
+ { text: '…' } }))`. On the inline transport nothing listens, so the same
65
+ line is a harmless no-op there.
66
+
67
+ ### Added
68
+
69
+ - **Warning and success inks, and fills for all three status roles.**
70
+ `Studio::ThemeResolver` now emits `--color-warning-ink` and
71
+ `--color-success-ink` beside `--color-danger-ink`, and the preset maps them
72
+ to `text-warning-ink` and `text-success-ink`. It also registers `success`,
73
+ `warning` and `danger` as background and border colours, so `bg-warning/10`
74
+ and `border-danger/30` compile. It deliberately registers no bare
75
+ `text-<role>`: every default role colour fails AA as text on the light
76
+ surfaces (warning #FF7C47 2.04:1, success #4BAF50 2.22:1, danger #EF4444
77
+ 3.01:1). The warning and success inks are read inside their own
78
+ `bg-<role>/10` badge, so `ThemeResolver#status_ink` also counts that tint as
79
+ a surface; tuned to the bare surfaces alone, the warning ink measured 3.91:1
80
+ there. `--color-danger-ink` is unchanged. Danger text belongs on a theme
81
+ surface, never a danger tint, and the vocabulary guard now refuses that
82
+ combination in engine views. `Studio::ColorScale.blend` composites a
83
+ translucent fill over a surface. Guarded by
84
+ `test/lib/status_ink_contrast_test.rb`.
85
+
86
+ ### Changed
87
+
88
+ - **The vocabulary guard's Tailwind build reads only its probe.** Tailwind v4
89
+ auto-detects sources from the working directory, which is this repo, so an
90
+ empty probe compiled to 75 KB. `source(none)` now makes the probe the only
91
+ source. The build recipe moved to `test/support/engine_tailwind_build.rb`,
92
+ where the overlay test shares it.
93
+
7
94
  ### Docs
8
95
 
96
+ - **The README stops describing a sign-in card the engine no longer ships.**
97
+ Its "credential slot" section still named `style/modals/_auth` — deleted with
98
+ the other mirrors in PR #319 — as the renderer of solana-studio's
99
+ `solana_studio/auth/_wallet_credential`, while `lib/studio.rb` already said the
100
+ engine ships no auth card. It now says so too, names turf-monster's
101
+ `app/views/modals/_auth.html.erb` as the authority both cite, and states that
102
+ the wallet slot currently has NO host: nothing renders it. Whether it should
103
+ have one is left as an open call. The store-name paragraph now reflects that
104
+ every store-taking partial validates through `Studio::JsIdentifier`, and
105
+ `Studio::JsLiteral.in_attribute` is described exactly (it runs
106
+ `escape_javascript`; ERB does the HTML half).
107
+ - **"Silent no-op" is gone from the modal partials, specimens and escapers.** A
108
+ quote or apostrophe that breaks an Alpine expression mounts a dead card, but the
109
+ vendored Alpine logs `Alpine Expression Error` for it. About a dozen comments —
110
+ shipped partials, specimens, the e2e lab, `JsLiteral` and `JsIdentifier` — said
111
+ otherwise; `_age_gate`'s also blamed `to_json`, which ERB entity-escapes. Four
112
+ files also claimed mcritchie-studio and turf-monster fork `studio/modals/_host`;
113
+ neither has since 2026-08-28. Pinned in `test/docs/modal_host_contract_docs_test.rb`,
114
+ whose census reads `studio/modals`, `style/modals`, the dummy app, the two
115
+ modules and the README. The phrase still survives outside it, in the two e2e
116
+ specs, `studio/emails/show` and `test/integration/style_page_test.rb`.
117
+
9
118
  - **Thirty-five minor versions of shipped entries left `## Unreleased` and moved
10
119
  under the version that actually shipped them.** The block spanned 2,382 lines and every
11
120
  entry in it had already been published — `accepted` sat on the `v0.74.4` release
@@ -32,8 +141,29 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
32
141
  fails loudly rather than passing vacuously, and — the one that would have caught
33
142
  this in week one — `Studio::VERSION` no more than two minor versions ahead of the
34
143
  newest heading. Against the pre-change file it reports "35 minor versions of
35
- entries are still filed under `## Unreleased`". Automating the roll inside
36
- `bin/release prepare` (mcritchie-studio) is the real fix and is not done.
144
+ entries are still filed under `## Unreleased`". ~~Automating the roll inside
145
+ `bin/release prepare` (mcritchie-studio) is the real fix and is not done.~~ —
146
+ **superseded after 0.74.8:** mcritchie-studio#1344 made `prepare` roll this
147
+ file whenever it allocates a version, so "it never touches this file" above
148
+ describes the tooling as of 0.74.8. See the entry on the automatic roll below.
149
+
150
+ - **The docs called the `## Unreleased` roll a manual conductor step; `bin/release
151
+ prepare` does it now, and the docs say exactly when it does not.**
152
+ mcritchie-studio#1344 made prepare roll this file into the allocated version in
153
+ the commit that sets `lib/studio/version.rb` and `Gemfile.lock`, and REFUSE the
154
+ sweep, with nothing published, when the roll would lie. `docs/RELEASE.md`
155
+ (*Rolling `Unreleased` into a version*), this file's preamble and the README's
156
+ release paragraph still said the roll was yours, by hand. They now name the
157
+ three outcomes (ALLOCATE rolls, SKIP rolls nothing, REFUSE aborts), each
158
+ refusal and its fix, and the reach limit: a SKIP (a version set by hand, or a
159
+ re-run after an abort before the tag) rolls nothing. They also settle three
160
+ conventions. An entry-less release still gets its heading; a heading never
161
+ lands before its version does, because `accepted` lags `release`; and a merge
162
+ that crosses a roll gets checked, because git can merge a new bullet CLEAN
163
+ under a version that shipped without it. The manual procedure stays, as the
164
+ remedy for those cases. No runtime code changed;
165
+ `test/docs/changelog_structure_test.rb` changed only its comments and its
166
+ failure message.
37
167
 
38
168
  ## 0.74.3 — 2026-09-08
39
169
 
data/README.md CHANGED
@@ -240,35 +240,79 @@ the block:
240
240
 
241
241
  #### Writing a modal's content partial — two rules the host imposes
242
242
 
243
- Both of these fail SILENTLY. Nothing raises, nothing logs, and the card renders;
244
- it just does less than it looks like it does.
245
-
246
- **1. SINGLE ROOT.** A content partial's outer `<div>` is the host's required
247
- root. Alpine's `<template x-if>` clones only the FIRST root element of its
248
- content, so a second top-level sibling a stray `<span>`, a trailing `<style>`
249
- block, a comment-turned-node is dropped on the floor. Bake anything extra
250
- inside the wrapping `<div>`.
251
-
252
- **2. NO DOUBLE QUOTE INSIDE `x-data`.** The attribute is double-quoted, so an
253
- inner double quote CLOSES it. Alpine then mounts a component whose expression is
254
- truncated mid-statement: every element still renders and every handler does
255
- nothing. Single-quote everything inside `x-data`.
243
+ Both rules let the card render and then do less than it looks like it does.
244
+ They differ in whether anything reaches the CONSOLE, which is the first thing
245
+ to check when a registered partial misbehaves.
246
+
247
+ **1. SINGLE ROOT fails in total silence.** A content partial's outer `<div>`
248
+ is the host's required root. Alpine's `<template x-if>` clones only the FIRST
249
+ root element of its content, so a second top-level sibling a stray `<span>`,
250
+ a trailing `<style>` block, a comment-turned-node — is dropped on the floor.
251
+ Nothing raises and nothing logs: `x-if` takes `.firstElementChild` and asks no
252
+ questions. (Alpine DOES warn on a multi-root `x-for` template *"x-for
253
+ templates require a single root element, additional elements will be ignored"*
254
+ which is exactly why this one catches people out. The modal host is `x-if`,
255
+ and `x-if` ships no such check.) Bake anything extra inside the wrapping
256
+ `<div>`.
257
+
258
+ **2. NO DOUBLE QUOTE INSIDE `x-data` — this one LOGS.** The attribute is
259
+ double-quoted, so an inner double quote CLOSES it. Alpine then mounts a
260
+ component whose expression is truncated mid-statement: every element still
261
+ renders and every handler does nothing. It is NOT silent. The truncation
262
+ leaves a JavaScript syntax error, Alpine's evaluator catches it, and the
263
+ default handler prints `Alpine Expression Error: …` together with the
264
+ offending expression — a `console.warn`, followed by an async rethrow. That
265
+ warning is the best clue this rule was broken, so send a debugging engineer to
266
+ the console rather than to the markup. Single-quote everything inside
267
+ `x-data`.
268
+
269
+ Interpolated values are the same rule arriving from the server, and Rails
270
+ already guards the ordinary case: `<%= value %>` inside `x-data` renders a
271
+ double quote as `&quot;`, and the HTML tokenizer never reads a character
272
+ reference as the closing quote — it decodes it straight into the value — so
273
+ the attribute survives. The vector is a value that SKIPS that escaping —
274
+ `raw`, `.html_safe`, or a helper returning a SafeBuffer — which puts a bare
275
+ `"` into the attribute and closes it exactly as a typed one would.
276
+ `escape_javascript` (`j`) is NOT the guard here: it escapes for a JavaScript
277
+ string literal (`\"`) and PRESERVES the html_safe flag, so the raw quote still
278
+ reaches the attribute. Let Rails escape it.
279
+
280
+ That settles the ATTRIBUTE, not the JavaScript inside it. A value spliced into
281
+ a single-quoted JS string — the shape rule 2 steers you to — has a second way
282
+ out: a `'`. ERB escapes it to `&#39;`, the parser decodes that straight back
283
+ into the value, and the bare `'` ends the string: the same
284
+ `Alpine Expression Error`. Route such a value through
285
+ `Studio::JsLiteral.in_attribute(value)`. It runs `escape_javascript` and
286
+ returns an UNMARKED String, so ERB's `<%= %>` does the HTML half on output —
287
+ both escapers, in the order that works, provided you never `raw` it; `lib/studio/js_literal.rb` carries the full contract, identifier
288
+ position (`Studio::JsIdentifier`) included.
256
289
 
257
290
  These are properties of the HOST, not of any one card, and they apply to every
258
- consumer partial an app registers — not only to the specimens in this gem. They
259
- are recorded here because they used to be recorded only in the style guide's
260
- specimen headers, which made them deletable by a change that removed a specimen:
261
- retiring the `style/modals/_network_guard` and `_wallet_deposit` mirrors on
262
- 2026-09-09 would otherwise have taken the single-root rule out of both this repo
263
- and the app that inherited those cards.
264
-
265
- **Wallet modals moved to `solana-studio`.** The Connect Wallet picker, the Web3
266
- step-up card and the Phantom deep link used to ship here as
267
- `studio/modals/wallet_connect`, `studio/modals/web3_step_up` and
268
- `studio/solana/phantom_deeplink`. They now live in the **solana-studio** gem as
269
- `solana_studio/modals/wallet_connect`, `solana_studio/modals/web3_step_up` and
270
- `solana_studio/phantom_deeplink`; render them from those paths and read that
271
- gem's README for their locals and hooks.
291
+ consumer partial an app registers — not only to the specimens in this gem.
292
+
293
+ They are recorded HERE because this is the host's CONTRACT, and nothing else
294
+ in the repo is. Plenty of files state one or both rules, but each of them is
295
+ describing ITSELF a specimen header explaining its own markup, an asset
296
+ partial explaining its own root. Someone writing a NEW partial has no specimen
297
+ to read; they read the host's documentation, so that is where the rules have
298
+ to live.
299
+
300
+ And a specimen could not DEMONSTRATE rule 1 even in principle — many specimen
301
+ headers state it, but none can show it failing — which is what settles it: the style guide wraps EVERY registration's partial in a `<div>` of its own
302
+ `<template x-if="…"><div><%= render … %></div></template>` — and that
303
+ wrapper supplies the single root the rule is about. A specimen that broke the
304
+ rule would still render correctly in the guide, so the specimens cannot
305
+ demonstrate the failure they would be documenting. Pinned by
306
+ `test/docs/modal_host_contract_docs_test.rb`.
307
+
308
+ #### Wallet modals moved to `solana-studio`
309
+
310
+ The Connect Wallet picker, the Web3 step-up card and the Phantom deep link used
311
+ to ship here as `studio/modals/wallet_connect`, `studio/modals/web3_step_up`
312
+ and `studio/solana/phantom_deeplink`. They now live in the **solana-studio**
313
+ gem as `solana_studio/modals/wallet_connect`, `solana_studio/modals/web3_step_up`
314
+ and `solana_studio/phantom_deeplink`; render them from those paths and read
315
+ that gem's README for their locals and hooks.
272
316
 
273
317
  This is the two-template split: **BASE** is studio-engine + mcritchie-studio,
274
318
  **WEB3 ADD** is solana-studio + turf-monster. The engine has no business
@@ -281,53 +325,59 @@ What the engine **keeps** is the SESSION half of Solana sign-in:
281
325
  `Studio.wallet_sign_in_statement` — the single source for the signed statement,
282
326
  which the gem's deep link reads so the two cannot drift.
283
327
 
284
- #### The auth modal's credential slot
285
-
286
- The **sign-in modal itself stays here** and is never forked: every app in the
287
- ecosystem signs people in, and most of them are web2. What varies is *which
288
- credentials it offers*. Google and magic-link are implemented by this engine and
289
- render directly. The wallet button is implemented by the web3 layer, so it is
290
- **contributed** `style/modals/_auth` looks for a partial at one fixed path and
291
- renders whatever it finds:
328
+ The callback view is also where a redirect-transport transaction waits while
329
+ the server cosigns and confirms, and it does not know what the user was doing.
330
+ The registered intent does, so the intent narrates: from its `complete()` it
331
+ dispatches `studio:wallet-progress` with `{ detail: { text: '…' } }`, and the
332
+ page writes that text into its status line. With nothing pushed the line reads
333
+ "Processing your wallet's response...", which names no wallet brand. On the
334
+ inline transport nothing listens, so the dispatch is a harmless no-op there.
292
335
 
293
- solana_studio/auth/_wallet_credential (shipped by the solana-studio gem)
294
-
295
- Bundling the gem **is** the registration. There is no registry call and no
296
- config flag; an app without that layer renders nothing there, so a web2 app
297
- carries no wallet markup at all rather than markup hidden behind a flag. This is
298
- the same optional-partial convention as `modals/_host_extras` above, and it uses
299
- the same three-term `lookup_context.exists?`.
300
-
301
- Copying the auth modal into the gem was rejected deliberately: it would fork a
302
- surface both apps sign in through, and the two copies would drift — which is how
303
- the wallet picker reached three copies before it was promoted.
336
+ #### The auth modal's credential slot
304
337
 
305
- **Two layers answer two different questions, and they are not merged:**
338
+ **This engine ships no sign-in card.** Each app owns its own and computes its
339
+ credential buttons itself: turf-monster's is `app/views/modals/_auth.html.erb`,
340
+ which is also the authority `lib/studio.rb` cites beside the Solana routes. The
341
+ engine's one auth card was the living style guide's MIRROR of turf's,
342
+ `style/modals/_auth`, and it was retired with the other mirrors on 2026-09-09
343
+ (PR #319). For sign-in the engine now ships fragments, not a card:
344
+ `studio/modals/shared/_email_field` and `studio/modals/auth/_resend_footer`,
345
+ each carded on the style guide.
346
+
347
+ **The wallet credential slot has no host today.** solana-studio still ships
348
+ `solana_studio/auth/_wallet_credential`, a Solana button written to be
349
+ *contributed* into a sign-in card rather than copied into one. The retired
350
+ mirror was its only renderer. Neither turf-monster nor mcritchie-studio names
351
+ the path; turf's card draws its wallet button directly. So bundling solana-studio
352
+ no longer puts a wallet button anywhere. Whether the engine should host the slot
353
+ again, or the convention should be retired, is an open call, not a fact this
354
+ README can settle.
355
+
356
+ **A card that does host it owns the gate.** The retired mirror's rules still
357
+ hold; they simply live in that app's view now. Two layers answer two different
358
+ questions, and they are not merged:
306
359
 
307
360
  | Question | Answered by | Where it is decided |
308
361
  |---|---|---|
309
- | Is it **implemented**? | the picker is registered, **and** the credential partial resolves | Ruby, in `style/modals/_auth` — gates the render |
362
+ | Is it **implemented**? | the picker is registered, **and** the credential partial resolves (`lookup_context.exists?`, the same three-term check as `modals/_host_extras`) | Ruby, in the hosting card — gates the render |
310
363
  | Should it **show**? | `methodOn('wallet')`, falling back to `Studio.auth_method?(:wallet) && Studio.feature?(:web3)` | Alpine, inside the contributed partial — gates visibility |
311
364
 
312
365
  Both terms of the Ruby gate are load-bearing, and they fail differently. Without
313
366
  the **registration** term a layer that ships the credential but not the picker
314
367
  draws a button that opens an empty panel — not hypothetical, solana-studio 0.5.2
315
- shipped exactly that pair. Without the **existence** term an app whose picker
368
+ shipped exactly that pair. Without the **existence** term a card whose picker
316
369
  resolves but whose credential does not raises `Missing partial` in front of
317
- someone signing in, instead of quietly rendering no button.
318
-
319
- Policy stays **out** of the Ruby gate on purpose. Folding
320
- `auth_method?(:wallet)` and `feature?(:web3)` into the render would delete the
321
- button from the DOM on a web3-off app that bundles the gem — and the "or"
322
- divider reads `methodOn('wallet')` too, so ticking Solana Wallet on the style
323
- guide's Sign in card would then float a divider above a button that is not
324
- there.
370
+ someone signing in, instead of quietly rendering no button. Keep policy out of
371
+ the Ruby gate: folding `auth_method?(:wallet)` and `feature?(:web3)` into the
372
+ render deletes the button from the DOM, while an "or" divider that reads
373
+ `methodOn('wallet')` still draws above the gap.
325
374
 
326
- A contributed partial renders inside the modal's own Alpine scope, so it may use
375
+ The partial renders inside the hosting card's Alpine scope, so it may use
327
376
  `methodOn(...)`, `attested()` (the legal-age gate — call it, or wallet becomes
328
377
  the one credential that skips attestation) and `props.submitting`. It receives
329
- one local, `modal_store`: the engine's real host is `"modals"`, the living style
330
- guide's page-scoped host is `"dsModals"`.
378
+ one local, `modal_store`, the store backing that card's host. solana-studio's
379
+ own header for the partial still describes the engine as its renderer; that is
380
+ the same stale claim, one repo over.
331
381
 
332
382
  **Name a store like a JavaScript identifier.** Partials splice this local in as a
333
383
  bare name — `$store.<name>.current()` — rather than as a string, so a value
@@ -336,10 +386,11 @@ SyntaxError in the whole `x-data`, and Alpine mounts a component that renders
336
386
  every element and does nothing. Escaping is not the repair (an escaped identifier
337
387
  is a different SyntaxError); a name that matches `/\A[A-Za-z_$][A-Za-z0-9_$]*\z/`
338
388
  is. `studio/modals/onboarding/_first_name` enforces exactly that and raises
339
- `ArgumentError` on anything else the first partial to do so, not yet the fleet.
340
- Note the shape decides the repair, not the name of the local: `blocks/_birthday`
341
- and `blocks/_leveling_activity` pass `modal_store` in *string* position and
342
- correctly `escape_javascript` it instead.
389
+ `ArgumentError` on anything else, and every store-taking partial now does the
390
+ same through `Studio::JsIdentifier.validate!` the shell, the blocks and the
391
+ templates alike. Note the shape decides the repair, not the
392
+ name of the local: `blocks/_birthday` passes `modal_store` in *string* position
393
+ (`store: '…'`) and correctly `escape_javascript`s it instead.
343
394
 
344
395
  It also keeps two blocks the gem renders **by name** across the gem boundary:
345
396
  `studio/modals/blocks/wallet_brand_sprite` and `studio/modals/blocks/card_header`.
@@ -366,11 +417,13 @@ default to `"modals"`, so existing call sites are unchanged. The engine's
366
417
 
367
418
  Two things that will bite you:
368
419
 
369
- - **Render `scoped_host`, not `host`.** This is a non-isolated engine, so an app
370
- view at the same path shadows the engine's and `mcritchie-studio` and
371
- `turf-monster` both ship their own `app/views/studio/modals/_host.html.erb`.
372
- A page rendering `studio/modals/host` in those apps silently gets the app's
373
- fork. `scoped_host` is unforked everywhere.
420
+ - **Render `scoped_host`, not `host`.** `scoped_host` mounts the page's modals
421
+ on the page's OWN store, which is the point of a page-scoped host; `host` is
422
+ the app's shared one. The path matters too: this is a non-isolated engine, so
423
+ an app view at `app/views/studio/modals/_host.html.erb` would shadow the
424
+ engine's. No consumer ships one today — mcritchie-studio and turf-monster
425
+ deleted their forks on 2026-08-28 — but nothing stops the next app, and
426
+ `scoped_host` has never been forked.
374
427
  - **Guard registrations with `current()?.id`.** The outer template unmounts one
375
428
  tick *after* the stack empties, so a bare `.id` throws on every close.
376
429
 
@@ -815,11 +868,14 @@ let it allocate the version — it derives the bump, commits `lib/studio/version
815
868
  with its `Gemfile.lock` onto **`origin/release`**, then publishes, tags, and bumps
816
869
  each consumer's lock. **Do not set the version by hand.** A hand-set number makes
817
870
  the allocation read the current version as already past the last tag and skip, so
818
- the hand number silently wins over the derived one. **`prepare` does not touch
819
- [`CHANGELOG.md`](./CHANGELOG.md)** renaming `## Unreleased` to the allocated
820
- version and opening a fresh empty one is a manual last step, and skipping it is
821
- how thirty-five minor versions of entries ended up filed as pending. Details and the
822
- exact commands are in [`docs/RELEASE.md`](./docs/RELEASE.md).
871
+ the hand number silently wins over the derived one, and the skip rolls no
872
+ changelog. **When it allocates, `prepare` also rolls
873
+ [`CHANGELOG.md`](./CHANGELOG.md)** in the same commit: everything under
874
+ `## Unreleased` moves beneath the new version's heading, written even when the
875
+ bucket is empty, and `## Unreleased` stays first and empty. It refuses the sweep,
876
+ with nothing published, when the roll would lie or it cannot read the file.
877
+ [`docs/RELEASE.md`](./docs/RELEASE.md), *Rolling `Unreleased` into a version*,
878
+ says exactly when it rolls, refuses or skips, and what to do by hand.
823
879
 
824
880
  **Semver guide** — the release *derives* the bump from its members (a `breaking`
825
881
  risk tag → major, a `feature` → minor, otherwise patch), so this is what those
@@ -26,6 +26,7 @@
26
26
  Action:
27
27
  .hold-btn press-and-hold confirm button (ported, TM)
28
28
  .hold-stack/.fizz-bit the carbonation behind it (ported, TM)
29
+ .email-reject email field rejection shake + fade (extracted, TM)
29
30
 
30
31
  Leveling:
31
32
  .level-badge(-1..-10) progressive level pill ladder (ported, TM)
@@ -74,9 +75,9 @@
74
75
  - Every tuning knob (duration, size, color, %, delay) is a CSS custom property
75
76
  with a sensible themed default, so a caller retunes via `style="--x: ..."`
76
77
  or a `:root` override WITHOUT forking the rule.
77
- - Namespacing: the two extracted primitives keep their source names
78
- (`.studio-border-glow`, `.loading-dots`) so the follow-up de-fork is a pure
79
- delete-the-local-copy swap with no view churn; their gem rules are byte-
78
+ - Namespacing: the extracted primitives keep their source names
79
+ (`.studio-border-glow`, `.loading-dots`, `.email-reject`) so the follow-up
80
+ de-fork is a pure delete-the-local-copy swap with no view churn; their gem rules are byte-
80
81
  compatible with the app copies, so a transient co-load is a harmless
81
82
  duplicate. The reconciled/fresh primitives use fresh generic names
82
83
  (`.spinner`, `.sheen`, `.ping`, `.fade-edge`, `.progress-meter`) that no app
@@ -191,6 +192,13 @@
191
192
  Usage: <span class="spinner"></span>
192
193
  <span class="spinner" style="--spinner-size: 2rem"></span>
193
194
  on a colored button: <span class="spinner" style="--spinner-color: currentColor"></span>
195
+ the currentColor RING the engine's own CTA blocks use, matching the
196
+ ring turf-monster's host-only .cta-spinner draws:
197
+ <span class="spinner" style="--spinner-track: currentColor; --spinner-color: transparent; opacity: 0.85"></span>
198
+ Mind the mapping, it reads backwards: --spinner-track paints the
199
+ WHOLE ring and --spinner-color only the leading segment. Untuned on a
200
+ .btn-primary, the head is --color-cta, the button's own fill, so the
201
+ spinner shows nothing but its faint track.
194
202
  Tune: --spinner-size / --spinner-thickness / --spinner-track /
195
203
  --spinner-color / --spinner-speed
196
204
  ========================================================================== */
@@ -1865,4 +1873,44 @@ body.modal-open { overflow: visible; }
1865
1873
  transition: none !important;
1866
1874
  }
1867
1875
  }
1876
+
1877
+ /* =============================================================================
1878
+ 13. email-reject — the email field's submit-rejection "no" (extracted, TM)
1879
+ A short horizontal shake plus a danger border that fades back to the resting
1880
+ border .input-field uses (border-strong), so it settles with no flash.
1881
+ studio/modals/shared/_email_field binds it as :class="{ 'email-reject':
1882
+ _rejecting }" when rendered with validator: true; the consumer's
1883
+ emailValidator factory owns the _rejecting STATE, and the engine paints it.
1884
+
1885
+ The name and keyframes are byte-compatible with turf-monster's local
1886
+ @utility, so the two co-load harmlessly until turf deletes its copy. Turf's
1887
+ utilities-layer rule outranks this one until then, so the reduced-motion
1888
+ branch below reaches turf only after that delete.
1889
+
1890
+ Tune: --email-reject-duration (default 0.55s)
1891
+ ========================================================================== */
1892
+ .email-reject {
1893
+ animation: email-reject var(--email-reject-duration, 0.55s) ease-in-out;
1894
+ }
1895
+
1896
+ @keyframes email-reject {
1897
+ 0% { transform: translateX(0); border-color: var(--color-danger); }
1898
+ 15% { transform: translateX(-7px); }
1899
+ 30% { transform: translateX(7px); }
1900
+ 45% { transform: translateX(-5px); }
1901
+ 60% { transform: translateX(4px); }
1902
+ 75% { transform: translateX(-2px); }
1903
+ 100% { transform: translateX(0); border-color: var(--color-border-strong, inherit); }
1904
+ }
1905
+
1906
+ /* Reduced motion keeps the signal and drops the shake: the border still flashes
1907
+ danger and fades back, and nothing moves. */
1908
+ @keyframes email-reject-fade {
1909
+ 0% { border-color: var(--color-danger); }
1910
+ 100% { border-color: var(--color-border-strong, inherit); }
1911
+ }
1912
+
1913
+ @media (prefers-reduced-motion: reduce) {
1914
+ .email-reject { animation-name: email-reject-fade; }
1915
+ }
1868
1916
  }
@@ -34,7 +34,7 @@
34
34
  <span x-text="croppedUrl ? 'Change Photo' : 'Upload Photo'"></span>
35
35
  </button>
36
36
  <template x-if="croppedUrl">
37
- <button type="button" @click="removePhoto()" class="text-muted hover:text-danger text-xs transition">Remove</button>
37
+ <button type="button" @click="removePhoto()" class="text-muted hover:text-danger-ink text-xs transition">Remove</button>
38
38
  </template>
39
39
  </div>
40
40
 
@@ -23,7 +23,7 @@
23
23
  }
24
24
  ">
25
25
  <input type="text" name="q" value="<%= params[:q] %>" placeholder="Search errors... (Esc to clear)" autofocus x-ref="input"
26
- class="input-field pr-10 py-2.5 text-sm placeholder-muted"
26
+ class="input-field pr-10 py-2.5 text-sm placeholder:text-muted"
27
27
  :readonly="loading" />
28
28
  <div x-show="loading" x-cloak class="absolute right-3 top-1/2 -translate-y-1/2">
29
29
  <svg class="animate-spin h-4 w-4 text-primary" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
@@ -7,7 +7,7 @@
7
7
  <div class="mb-6 relative">
8
8
  <input type="text" placeholder="Filter tables..." x-model="search"
9
9
  @keydown.escape.prevent="search = ''"
10
- class="input-field pr-10 py-2.5 text-sm placeholder-muted" />
10
+ class="input-field pr-10 py-2.5 text-sm placeholder:text-muted" />
11
11
  <button x-show="search.length > 0" x-cloak @click="search = ''"
12
12
  class="absolute right-3 top-1/2 -translate-y-1/2 text-muted hover:text-heading transition">
13
13
  <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
@@ -47,7 +47,7 @@
47
47
  <td class="px-4 py-1.5">
48
48
  <span class="inline-flex gap-1.5 flex-wrap">
49
49
  <% unless col.null %>
50
- <span class="text-warning font-medium">NOT NULL</span>
50
+ <span class="text-warning-ink font-medium">NOT NULL</span>
51
51
  <% end %>
52
52
  <% if col.default.present? %>
53
53
  <span class="text-muted">default: <span class="text-secondary font-mono"><%= col.default %></span></span>
@@ -22,7 +22,7 @@
22
22
  x-transition:leave="transition ease-in duration-500"
23
23
  x-transition:leave-start="opacity-100"
24
24
  x-transition:leave-end="opacity-0"
25
- class="absolute inset-0 z-10 rounded-2xl cursor-pointer flex items-center justify-center backdrop-overlay">
25
+ class="absolute inset-0 z-10 rounded-2xl cursor-pointer flex items-center justify-center backdrop-blur-[2px] backdrop-brightness-70 bg-primary-900/20">
26
26
  <span class="text-secondary text-xs font-medium uppercase tracking-wider">Click to show other options</span>
27
27
  </div>
28
28
  <% end %>
@@ -1,7 +1,14 @@
1
1
  <div class="flex flex-col items-center justify-center min-h-[60vh] px-4">
2
2
  <div class="text-center">
3
3
  <div id="phantom-spinner" class="inline-block w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin mb-4"></div>
4
- <p id="phantom-status" class="text-secondary text-sm">Processing Phantom response...</p>
4
+ <%# THE ONE SENTENCE THE USER READS for the whole return leg, and on a
5
+ transaction that leg includes the server's cosign, broadcast and confirm.
6
+ It names the act and not a vendor: the redirect transport returns
7
+ Phantom, Solflare and Backpack users here alike. The id keeps its old name
8
+ because the script below addresses it by that name. A registered intent
9
+ replaces this text through the studio:wallet-progress event (see the
10
+ resume branch below); with nothing pushed it stays, and stays true. %>
11
+ <p id="phantom-status" class="text-secondary text-sm">Processing your wallet's response...</p>
5
12
  <p id="phantom-error" class="text-red-400 text-sm mt-2 hidden"></p>
6
13
  </div>
7
14
 
@@ -169,6 +176,37 @@
169
176
  if (hasResumer && studio.walletJournal.peek()) {
170
177
  dbg('Transport', 'wallet_dl journal present — resuming through walletOps');
171
178
 
179
+ // THE PROGRESS SEAM — the intent speaks, this page listens.
180
+ //
181
+ // THE DEFECT IT CLOSES. The status line used to change only on the legacy
182
+ // path below, past this branch's return, so a transaction resumed here read
183
+ // one unchanging sentence while the server cosigned, broadcast and
184
+ // confirmed: 18 seconds measured on a QA iPhone for a contest entry. That
185
+ // reads as a hang. The registered intent knows what the server is doing and
186
+ // has no surface on this document; this page has the surface and, by
187
+ // design, no idea. So the intent dispatches, from its complete():
188
+ //
189
+ // document.dispatchEvent(new CustomEvent('studio:wallet-progress',
190
+ // { detail: { text: 'Cosigning and submitting to Solana...' } }));
191
+ //
192
+ // An EVENT, not a function the intent must find, because complete() runs on
193
+ // both transports: on the inline one there is no listener and the dispatch
194
+ // is a harmless no-op, so the intent needs no check for which document it
195
+ // is on. And an event, not an option handed through resume, because
196
+ // solana-studio's resume calls complete() with ctx, result and state and
197
+ // nothing else, so there is no bag to carry one in.
198
+ //
199
+ // INSTALLED BEFORE resume, never after: resume calls complete() in the same
200
+ // tick, so a push made before its first await would land on no listener.
201
+ // textContent, never markup. A push with no text is ignored, because a blank
202
+ // status line reads as the hang this seam exists to end.
203
+ document.addEventListener('studio:wallet-progress', function(e) {
204
+ var text = e && e.detail && e.detail.text;
205
+ if (typeof text !== 'string' || !text.trim()) { return; }
206
+ dbg('Progress', text);
207
+ statusEl.textContent = text;
208
+ });
209
+
172
210
  studio.walletOps.resume(params, {
173
211
  navigate: function(url) { window.location.href = url; }
174
212
  }).then(function(result) {
@@ -26,7 +26,7 @@
26
26
  <% end %>
27
27
 
28
28
  <% flash.each do |type, message| %>
29
- <div class="mb-4 rounded-lg px-4 py-3 text-sm <%= type.to_s == "alert" ? "bg-danger/10 text-danger" : "bg-success/10 text-success" %>">
29
+ <div class="mb-4 rounded-lg px-4 py-3 text-sm bg-surface border <%= type.to_s == "alert" ? "border-danger/30 text-danger-ink" : "border-success/30 text-success-ink" %>">
30
30
  <%= message %>
31
31
  </div>
32
32
  <% end %>
@@ -15,9 +15,10 @@
15
15
  rather than the app's shared `modals` store, so this page works identically
16
16
  in an app that renders a shared modal host (mcritchie-studio, turf-monster)
17
17
  and one that renders none at all (mcritchie-industries, moms-app). It uses
18
- studio/modals/scoped_host, NOT studio/modals/host — the latter is forked by
19
- mcritchie-studio and turf-monster, and an app fork shadows the engine's
20
- partial in this non-isolated engine. %>
18
+ studio/modals/scoped_host, NOT studio/modals/host — host is the app's
19
+ shared store, and in this non-isolated engine an app copy of it would shadow
20
+ the engine's partial. mcritchie-studio and turf-monster deleted theirs on
21
+ 2026-08-28; scoped_host's path has never been forked. %>
21
22
  <% content_for(:title) { "Emails" } %>
22
23
  <%
23
24
  max_width = Studio::EmailCatalog::MAX_WIDTH
@@ -15,7 +15,7 @@
15
15
  class: "text-sm text-muted hover:text-heading underline underline-offset-2" %>
16
16
  <h1 class="text-3xl font-bold text-heading mt-2">Unregistered email</h1>
17
17
  <div class="flex flex-wrap items-center gap-2 mt-3">
18
- <span class="badge bg-warning/10 text-warning border-warning/30 whitespace-nowrap">Not registered</span>
18
+ <span class="badge bg-warning/10 text-warning-ink border-warning/30 whitespace-nowrap">Not registered</span>
19
19
  <span class="font-mono text-2xs text-muted"><%= @key %></span>
20
20
  </div>
21
21
  </header>
@@ -66,16 +66,16 @@
66
66
  badge_class, badge_label, badge_note =
67
67
  case source
68
68
  when :app
69
- ["badge bg-success/10 text-success border-success/30", "Uploaded here",
69
+ ["badge bg-success/10 text-success-ink border-success/30", "Uploaded here",
70
70
  "Stored for #{Studio.app_name} — this upload is what ships."]
71
71
  when :app_asset
72
- ["badge bg-success/10 text-success border-success/30", "#{Studio.app_name}'s artwork",
72
+ ["badge bg-success/10 text-success-ink border-success/30", "#{Studio.app_name}'s artwork",
73
73
  "Committed in this app's own assets. Upload to replace it."]
74
74
  when :engine_default
75
75
  ["badge bg-inset text-muted border-subtle", "Studio default",
76
76
  "Shared artwork that ships with the engine. Upload to make it #{Studio.app_name}'s."]
77
77
  else
78
- ["badge bg-warning/10 text-warning border-warning/30", "No image",
78
+ ["badge bg-warning/10 text-warning-ink border-warning/30", "No image",
79
79
  "This email sends without a banner."]
80
80
  end
81
81
  %>
@@ -476,7 +476,7 @@
476
476
  </div>
477
477
  <% end %>
478
478
  <% if @preview_error.present? %>
479
- <p class="text-2xs text-danger mt-2 px-1 font-mono break-all"><%= @preview_error %></p>
479
+ <p class="text-2xs text-danger-ink mt-2 px-1 font-mono break-all"><%= @preview_error %></p>
480
480
  <% end %>
481
481
  </section>
482
482
 
@@ -507,9 +507,10 @@
507
507
 
508
508
  <% if @uploads_available %>
509
509
  <%# Cropper + the page-scoped modal host, exactly as the index mounts them:
510
- scoped_host rather than modals/host, because mcritchie-studio and
511
- turf-monster both ship app copies of modals/host that shadow the engine's
512
- in this non-isolated engine.
510
+ scoped_host rather than modals/host, because this page mounts its modals
511
+ on its OWN store, and because scoped_host's path has never been forked
512
+ (an app copy of modals/host would shadow the engine's in this
513
+ non-isolated engine; none ships one today).
513
514
 
514
515
  Optional chaining on current() is load-bearing: the outer template
515
516
  unmounts one tick AFTER the stack empties, so a bare .id throws on every
@@ -28,8 +28,9 @@
28
28
  <% crop_store = local_assigns.fetch(:store, "modals") %>
29
29
  <%# STRING position, so it is ESCAPED rather than validated: the store name is
30
30
  handed to cropPhotoModal as a JS string argument, not spliced in as a bare
31
- identifier. An apostrophe would close that literal and mount the modal as a
32
- silent no-op that still renders every element below. Studio::JsLiteral carries
31
+ identifier. An apostrophe would close that literal and mount the modal dead:
32
+ every element below still renders, and the only signal is an Alpine
33
+ Expression Error in the browser console. Studio::JsLiteral carries
33
34
  why both escapers have to run. %>
34
35
  <% crop_store_js = Studio::JsLiteral.in_attribute(crop_store) %>
35
36
  <div x-data="cropPhotoModal({ store: '<%= crop_store_js %>' })">
@@ -32,7 +32,11 @@
32
32
  IMPOSES on the content partials registered in its block: a SINGLE ROOT
33
33
  element (a template x-if clones only the first one; later siblings are
34
34
  dropped) and NO double quote inside x-data (it closes the attribute and
35
- Alpine mounts a silent no-op). Both fail without raising.
35
+ Alpine mounts a no-op). NEITHER STOPS THE RENDER, BUT ONLY ONE IS SILENT:
36
+ the single-root miss logs nothing at all, while the truncated x-data prints
37
+ "Alpine Expression Error" to the console (and rethrows it async). That
38
+ warning is the clue rule one never gives you, so check the console before
39
+ you re-read the markup.
36
40
  %>
37
41
  <style>
38
42
  /* Scroll lock applied by $store.modals._sync() when the stack is non-empty.
@@ -5,12 +5,13 @@
5
5
 
6
6
  Why this exists as a separate partial rather than a local on
7
7
  studio/modals/_host: this is a NON-ISOLATED engine, so an app view at the same
8
- path SHADOWS the engine's. `mcritchie-studio` and `turf-monster` both ship
9
- their own app/views/studio/modals/_host.html.erb — older, simpler copies that
10
- know nothing of a store: local. A page rendering "studio/modals/host" in those
11
- apps silently gets the fork, and its page-scoped store is never registered.
12
- This partial's path is unforked in every app, so it renders the engine's code
13
- everywhere. (The living style guide hand-rolled the same thing inline for the
8
+ path SHADOWS the engine's. mcritchie-studio and turf-monster each carried an
9
+ app copy of app/views/studio/modals/_host.html.erb — older, simpler copies that
10
+ knew nothing of a store: local until both were deleted on 2026-08-28. No consumer
11
+ forks _host today, but nothing stops the next one, and a page rendering
12
+ "studio/modals/host" in such an app would silently get the fork with its
13
+ page-scoped store never registered. This partial's path has never been forked,
14
+ so it renders the engine's code everywhere. (The living style guide hand-rolled the same thing inline for the
14
15
  same reason; it can rebase onto this later.)
15
16
 
16
17
  Two independent problems it solves at once:
@@ -41,11 +42,12 @@
41
42
  chaining (`current()?.id`): the outer template unmounts one tick AFTER the
42
43
  stack empties, so a bare `.id` throws on every close.
43
44
 
44
- THE CONTENT PARTIALS OBEY THE SAME TWO RULES AS THE SHARED HOST'S, and both
45
- fail silently: a SINGLE ROOT element (a template x-if clones only the first
46
- one) and NO double quote inside x-data (it closes the attribute and Alpine
47
- mounts a component that renders everything and does nothing). Stated in full
48
- in the gem README's "Modal host" section.
45
+ THE CONTENT PARTIALS OBEY THE SAME TWO RULES AS THE SHARED HOST'S, and they
46
+ fail DIFFERENTLY: a SINGLE ROOT element (a template x-if clones only the
47
+ first one, logging nothing) and NO double quote inside x-data (it closes the
48
+ attribute and Alpine mounts a component that renders everything and does
49
+ nothing -- but LOGS "Alpine Expression Error" on the way). Stated in full in
50
+ the gem README's "Modal host" section.
49
51
 
50
52
  AND EVERY ID REGISTERED HERE MUST BE REACHABLE, both ways round. A card that
51
53
  opens an id no template registers shows an EMPTY panel; a template no card
@@ -10,6 +10,29 @@
10
10
 
11
11
  Locals:
12
12
  modal_store — Alpine store name backing close(). Default "modals".
13
+
14
+ ERROR INK, not a static red. The error paragraph is text-danger-ink, the
15
+ per-theme red Studio::ThemeResolver derives to a 4.5:1 target. It replaced
16
+ text-red-400. Measured through the resolver on turf-monster's real palette
17
+ (primary 4BAF50), on the modal card, which is bg-surface: danger-ink is 4.50
18
+ on the dark card 3C3853 and 5.76 on the light card ffffff, clearing AA on
19
+ both; text-red-400 was 3.86 and 2.89, failing both. Those are Tailwind v4's
20
+ red-400, about FF6467, which is what these apps compile; the v3 hex F87171
21
+ gives 4.03 and 2.77 and fails the same way. No STATIC red clears AA
22
+ on BOTH themes — theme_resolver.rb carries the measured table and derives
23
+ --color-danger-ink precisely to be the text red. --color-danger is the brand
24
+ FILL and is free to be vivid; it is not this. Guarded, executably, by
25
+ test/views/resend_footer_error_contrast_test.rb.
26
+
27
+ SPINNER, tuned rather than re-named. This is the engine's canonical .spinner
28
+ primitive (engine-motion.css), tuned through its OWN documented custom
29
+ properties to the currentColor ring turf-monster paints with its host-only
30
+ .cta-spinner — so the block looks unchanged once turf renders it, and the
31
+ engine names no class it cannot define. Read the mapping carefully, because
32
+ it looks backwards: --spinner-track colours the WHOLE ring and
33
+ --spinner-color colours only the leading segment. A currentColor ring with a
34
+ transparent gap is therefore track currentColor plus color transparent, NOT
35
+ the reverse, which would draw a lone quarter-arc instead.
13
36
  %>
14
37
  <%
15
38
  # IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier.
@@ -19,9 +42,10 @@
19
42
  <button @click="resendMagicLink()" :disabled="props.submitting === 'magic-link' || (props.resendCooldown || 0) > 0"
20
43
  class="block mx-auto text-sm text-primary hover:text-primary-300 underline underline-offset-2 disabled:opacity-50 disabled:no-underline">
21
44
  <span class="inline-flex items-center justify-center gap-1.5">
22
- <span x-show="props.submitting === 'magic-link'" class="spinner" aria-hidden="true"></span>
45
+ <span x-show="props.submitting === 'magic-link'" class="spinner" aria-hidden="true"
46
+ style="--spinner-track: currentColor; --spinner-color: transparent; opacity: 0.85"></span>
23
47
  <span x-text="props.submitting === 'magic-link' ? 'Resending…' : ((props.resendCooldown || 0) > 0 ? ('Resend available in ' + props.resendCooldown + 's') : 'Resend link')"></span>
24
48
  </span>
25
49
  </button>
26
- <p role="alert" x-show="props.resendError" x-cloak class="text-xs text-red-400 mt-3 text-center" x-text="props.resendError"></p>
50
+ <p role="alert" x-show="props.resendError" x-cloak class="text-xs text-danger-ink mt-3 text-center" x-text="props.resendError"></p>
27
51
  <button @click="$store.<%= modal_store %>.close()" class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition">Close</button>
@@ -73,10 +73,14 @@
73
73
  get minAge() { return <%= min_age.present? ? min_age.to_i : 0 %> || this.props.minAge || null; },
74
74
  get stateCode() { return '<%= j state %>' || this.props.state || ''; },
75
75
  get headline() {
76
- // Single-quoted + j-escaped, NOT to_json: to_json emits DOUBLE quotes,
77
- // which close this double-quoted x-data attribute and mount the
78
- // component as a silent no-op. The markup still renders, so every
79
- // assertion passes while the card is dead in a browser.
76
+ // Single-quoted + j-escaped. A plain ERB to_json would in fact survive
77
+ // here: to_json returns an UNMARKED String, so ERB entity-escapes its
78
+ // quotes and this double-quoted x-data attribute stays whole. The
79
+ // hazard is a raw or html_safe one, whose bare quote closes the
80
+ // attribute and mounts the card dead: the markup still renders, every
81
+ // assertion passes, and the only signal is an Alpine Expression Error
82
+ // in the browser console. Studio::JsLiteral.in_attribute is the
83
+ // engine's contract for a value in this position.
80
84
  var t = '<%= j title.to_s %>';
81
85
  return t ? t : 'Easy, Young’un';
82
86
  },
@@ -131,7 +131,7 @@
131
131
  :disabled="!complete || submitting">
132
132
  <span x-show="!submitting">Confirm &amp; Continue</span>
133
133
  <span x-show="submitting" class="inline-flex items-center justify-center gap-2" style="display: none;">
134
- <span class="cta-spinner" aria-hidden="true"></span>
134
+ <span class="spinner" aria-hidden="true" style="--spinner-track: currentColor; --spinner-color: transparent; opacity: 0.85"></span>
135
135
  Verifying…
136
136
  </span>
137
137
  </button>
@@ -76,7 +76,8 @@
76
76
  # STRING position — clusterParam is a JS string on the root scope that
77
77
  # _success_card's tx-link concatenates into a URL. An apostrophe closes the
78
78
  # literal and the whole x-data becomes a SyntaxError, which mounts this card and
79
- # every nested block as a silent no-op. props_expr one line up is the OPPOSITE
79
+ # every nested block dead: all of it renders, and only the browser console says
80
+ # why (an Alpine Expression Error). props_expr one line up is the OPPOSITE
80
81
  # shape (an Alpine expression by contract) and is deliberately left alone.
81
82
  cluster_param_js = Studio::JsLiteral.in_attribute(cluster_param)
82
83
  lobby_url_key = local_assigns.fetch(:lobby_url_key, "props.lobbyUrl")
@@ -168,7 +168,7 @@
168
168
  <button type="submit" class="btn btn-primary btn-lg w-full" :disabled="saving || !changed">
169
169
  <span x-show="!saving" x-text="changed ? '<%= j cta_label %>' : '<%= j saved_label %>'"><%= cta_label %></span>
170
170
  <span x-show="saving" class="inline-flex items-center justify-center gap-2" style="display: none;">
171
- <span class="cta-spinner" aria-hidden="true"></span>
171
+ <span class="spinner" aria-hidden="true" style="--spinner-track: currentColor; --spinner-color: transparent; opacity: 0.85"></span>
172
172
  <span x-text="progressLabel || '<%= j saving_label %>'"><%= saving_label %></span>
173
173
  </span>
174
174
  </button>
@@ -83,8 +83,9 @@
83
83
  CRITICAL (Alpine): this partial is cloned from a <template x-if> by the modal
84
84
  host, so it must have ONE root element, and the x-data below is a
85
85
  DOUBLE-QUOTED attribute — a single " anywhere inside it (a code comment
86
- included) closes it early and the whole component mounts as a silent no-op that
87
- still renders markup. Keep every inner string SINGLE-quoted.
86
+ included) closes it early and the whole component mounts dead: it still renders
87
+ markup, and the only signal is an Alpine Expression Error in the browser
88
+ console. Keep every inner string SINGLE-quoted.
88
89
 
89
90
  THE TWO SHAPES — read this before adding a local to the x-data. Five locals are
90
91
  interpolated into that attribute and they do NOT take the same repair. Getting
@@ -93,7 +94,7 @@
93
94
 
94
95
  STRING position — empty_error, submit_path, skip_path, done_event — each sits
95
96
  inside a JS SINGLE-quoted literal. A bare apostrophe closes the literal, the
96
- expression becomes a SyntaxError, and the card is that silent no-op. Repair:
97
+ expression becomes a SyntaxError, and the card is that dead component. Repair:
97
98
  Studio::JsLiteral.in_attribute, which is where the mechanism now lives and is
98
99
  tested. Two escapers have to run — one for the JS literal, one for the
99
100
  attribute — and the second only runs on a value ERB still believes is unsafe.
@@ -146,8 +147,8 @@
146
147
  # first HOST-SUPPLIED PROSE to go inside that attribute, and prose has
147
148
  # apostrophes. Interpolated raw, an error reading "We'll need a name" would
148
149
  # close the JS single-quoted string, make the whole expression a SyntaxError,
149
- # and mount the component as a SILENT NO-OP that still renders every element
150
- # below. Studio::JsLiteral.in_attribute covers the apostrophe, the double quote,
150
+ # and mount the component DEAD: it still renders every element below, and
151
+ # only the browser console says why (an Alpine Expression Error). Studio::JsLiteral.in_attribute covers the apostrophe, the double quote,
151
152
  # the backslash and the newline, AND hands ERB a value it still believes is
152
153
  # unsafe so the attribute's own escaping runs too — a safe string would skip that
153
154
  # half and could smuggle a raw double quote in, the exact failure the CRITICAL
@@ -184,8 +185,8 @@
184
185
  # THE SAME HAZARD empty_error_js CARRIES, on the three remaining locals that land
185
186
  # inside a JS SINGLE-quoted literal in the x-data below. A bare apostrophe in any
186
187
  # of them closes its literal, the whole expression becomes a SyntaxError, and
187
- # Alpine mounts the component as a SILENT NO-OP that still renders every element
188
- # perfect-looking markup, dead card.
188
+ # Alpine mounts the component DEAD perfect-looking markup, a card that does
189
+ # nothing, and an Alpine Expression Error in the console as the only signal.
189
190
  #
190
191
  # WHY A METHOD RATHER THAN escape_javascript AT EACH SITE: the second escaper is
191
192
  # conditional. ERB skips a value that answers true to html_safe, and
@@ -377,7 +378,7 @@
377
378
  </template>
378
379
  <button type="submit" :disabled="submitting"
379
380
  class="btn btn-primary btn-lg w-full gap-2 disabled:cursor-wait">
380
- <span x-show="submitting" class="cta-spinner" aria-hidden="true"></span>
381
+ <span x-show="submitting" class="spinner" aria-hidden="true" style="--spinner-track: currentColor; --spinner-color: transparent; opacity: 0.85"></span>
381
382
  <span x-text="submitting ? 'Saving…' : 'Save and continue'"></span>
382
383
  </button>
383
384
  </form>
@@ -15,7 +15,8 @@
15
15
  path instead of two. Here the callback resolves locally.
16
16
 
17
17
  Single root, and everything inside the double-quoted x-data is single-quoted —
18
- an inner double quote closes the attribute and mounts a silent no-op.
18
+ an inner double quote closes the attribute and mounts a dead component whose
19
+ only signal is an Alpine Expression Error in the browser console.
19
20
  %>
20
21
  <div x-data="{
21
22
  email: '',
@@ -23,7 +23,8 @@
23
23
 
24
24
  Single root: the outer <div> is the host's required root, and everything inside
25
25
  the double-quoted x-data is single-quoted — an inner double quote closes the
26
- attribute and mounts the component as a silent no-op.
26
+ attribute and mounts the component dead, with an Alpine Expression Error in
27
+ the browser console as the only signal.
27
28
  %>
28
29
  <div x-data="{
29
30
  saving: false,
@@ -85,6 +85,12 @@ module Studio
85
85
  (hi + 0.05) / (lo + 0.05)
86
86
  end
87
87
 
88
+ # `top` painted at `alpha` over an opaque `bottom`: the colour a browser
89
+ # composites for a translucent fill like bg-danger/10 on the surface under it.
90
+ def self.blend(top, bottom, alpha)
91
+ rgb_to_hex(*hex_to_rgb(top).zip(hex_to_rgb(bottom)).map { |t, b| (t * alpha + b * (1 - alpha)).round })
92
+ end
93
+
88
94
  def self.with_opacity(hex, opacity)
89
95
  r, g, b = hex_to_rgb(hex)
90
96
  "rgba(#{r},#{g},#{b},#{opacity})"
@@ -5,9 +5,10 @@ module Studio
5
5
  # <button @click="$store.<%= modal_store %>.close()">
6
6
  #
7
7
  # THE SIBLING OF Studio::JsLiteral, AND ITS OPPOSITE. Both exist for the same
8
- # silent failure: a host value that makes an Alpine expression a SyntaxError, so
8
+ # quiet failure: a host value that makes an Alpine expression a SyntaxError, so
9
9
  # the component mounts as a NO-OP that still renders every element. Perfect
10
- # markup, dead card, nothing raised and nothing logged. The two differ on the
10
+ # markup, dead card, nothing raised server-side, and only an Alpine Expression
11
+ # Error in the browser console to say why. The two differ on the
11
12
  # only question that matters — WHAT THE VALUE IS:
12
13
  #
13
14
  # STRING position — the value is DATA inside a JS literal. Any character is
@@ -4,7 +4,9 @@ module Studio
4
4
  # ONE home for the repair that keeps a host-supplied value from bricking an
5
5
  # Alpine component when it is spliced into a JS-evaluating HTML attribute.
6
6
  #
7
- # THE FAILURE THIS EXISTS FOR is silent, which is the whole reason it is worth a
7
+ # THE FAILURE THIS EXISTS FOR is quiet no server error, no failing render
8
+ # test, only an Alpine Expression Error in the browser console — which is the
9
+ # whole reason it is worth a
8
10
  # module instead of a convention. A local sits inside a JS single-quoted literal
9
11
  # in a double-quoted attribute:
10
12
  #
@@ -31,6 +31,8 @@ module Studio
31
31
  dark_base = colors[:dark] || "#1A1535"
32
32
  primary = colors[:primary] || "#8E82FE"
33
33
  border_rgb = ColorScale.lighten(dark_base, 0.30)
34
+ success = colors[:success] || "#4BAF50"
35
+ warning = colors[:warning] || "#FF7C47"
34
36
  danger = colors[:danger] || "#EF4444"
35
37
  surfaces = dark_surfaces(dark_base)
36
38
 
@@ -69,8 +71,8 @@ module Studio
69
71
  "--color-shadow" => "transparent",
70
72
  "--color-cta" => primary,
71
73
  "--color-cta-hover" => ColorScale.darken(primary, 0.30),
72
- "--color-success" => colors[:success] || "#4BAF50",
73
- "--color-warning" => colors[:warning] || "#FF7C47",
74
+ "--color-success" => success,
75
+ "--color-warning" => warning,
74
76
  "--color-danger" => danger,
75
77
  # THE DANGER *INK*, which is not the danger *colour*. --color-danger is a
76
78
  # brand fill (button backgrounds, borders) and is free to be vivid;
@@ -85,8 +87,17 @@ module Studio
85
87
  # start: 0.0 is deliberate — the search tries the operator's actual danger
86
88
  # colour FIRST and blends only as far as AA demands, so a theme whose red
87
89
  # already passes keeps its exact brand hex.
90
+ #
91
+ # The warning and success inks are the same contract for the other two
92
+ # status roles: their default colours fail AA as text on the light
93
+ # surfaces too. They are found by #status_ink, which also counts the
94
+ # role's own tint as a surface, because they are read inside tinted
95
+ # badges. Danger text is not: it belongs on a theme surface, never on a
96
+ # danger tint (see #status_ink for why that is a rule, not an accident).
88
97
  "--color-danger-ink" => contrast_ink(danger, direction: :lighten, start: 0.0, target: 4.5,
89
- against: dark_surfaces(dark_base)),
98
+ against: surfaces),
99
+ "--color-warning-ink" => status_ink(warning, surfaces, direction: :lighten),
100
+ "--color-success-ink" => status_ink(success, surfaces, direction: :lighten),
90
101
  "--color-accent" => colors[:accent] || "#F72585"
91
102
  }
92
103
  end
@@ -132,6 +143,29 @@ module Studio
132
143
  louder ? secondary : muted
133
144
  end
134
145
 
146
+ # How strong a status role's tint is where its ink is read on it: the
147
+ # `bg-<role>/10 text-<role>-ink` badge and flash-panel pattern.
148
+ STATUS_TINT = 0.10
149
+
150
+ # The warning and success inks land on the page surfaces AND on their own
151
+ # role's tint over each of them (the `bg-warning/10 text-warning-ink`
152
+ # badge). The tint is darker than a light surface and lighter than a dark
153
+ # one, so an ink tuned to the bare surfaces alone came out below AA inside
154
+ # its own badge (default theme: warning 3.91:1 dark, success 4.00:1 dark).
155
+ # Counting the tints as surfaces closes that.
156
+ #
157
+ # --color-danger-ink does NOT go through here, and danger text must not sit
158
+ # on a danger tint: tuned to bare surfaces it measures 4.10:1 (light) and
159
+ # 4.18:1 (dark) on its own 10% tint. Consumers already build on that rule (turf-monster's error
160
+ # contrast guard keeps a control that fails the day danger-ink clears a red
161
+ # tint), so retuning danger-ink is a sequenced change, not a drive-by one.
162
+ # test/views/engine_class_vocabulary_test.rb refuses danger-ink on a danger
163
+ # tint in any engine view.
164
+ def status_ink(role, surfaces, direction:)
165
+ tints = surfaces.map { |bg| ColorScale.blend(role, bg, STATUS_TINT) }
166
+ contrast_ink(role, direction: direction, start: 0.0, target: 4.5, against: surfaces + tints)
167
+ end
168
+
135
169
  # Bounded, clamped search: raise the blend amount from `start` until the
136
170
  # ink clears `target` contrast against every background in `against`.
137
171
  # Clamps at 1.0 (pure white/black), so a pathological base degrades to the
@@ -169,6 +203,8 @@ module Studio
169
203
  def light_mode_vars
170
204
  light_base = colors[:light] || "#f8fafc"
171
205
  primary = colors[:primary] || "#8E82FE"
206
+ success = colors[:success] || "#4BAF50"
207
+ warning = colors[:warning] || "#FF7C47"
172
208
  danger = colors[:danger] || "#EF4444"
173
209
  surfaces = light_surfaces(light_base)
174
210
 
@@ -196,8 +232,8 @@ module Studio
196
232
  "--color-shadow" => "rgba(0,0,0,0.05)",
197
233
  "--color-cta" => primary,
198
234
  "--color-cta-hover" => ColorScale.darken(primary, 0.30),
199
- "--color-success" => colors[:success] || "#4BAF50",
200
- "--color-warning" => colors[:warning] || "#FF7C47",
235
+ "--color-success" => success,
236
+ "--color-warning" => warning,
201
237
  "--color-danger" => danger,
202
238
  # THE DANGER *INK*, which is not the danger *colour*. --color-danger is a
203
239
  # brand fill (button backgrounds, borders) and is free to be vivid;
@@ -212,8 +248,13 @@ module Studio
212
248
  # start: 0.0 is deliberate — the search tries the operator's actual danger
213
249
  # colour FIRST and blends only as far as AA demands, so a theme whose red
214
250
  # already passes keeps its exact brand hex.
251
+ #
252
+ # The warning and success inks are the same contract for the other two
253
+ # status roles (see the dark-mode note).
215
254
  "--color-danger-ink" => contrast_ink(danger, direction: :darken, start: 0.0, target: 4.5,
216
- against: light_surfaces(light_base)),
255
+ against: surfaces),
256
+ "--color-warning-ink" => status_ink(warning, surfaces, direction: :darken),
257
+ "--color-success-ink" => status_ink(success, surfaces, direction: :darken),
217
258
  "--color-accent" => colors[:accent] || "#F72585"
218
259
  }
219
260
  end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.74.8"
2
+ VERSION = "0.74.10"
3
3
  end
@@ -92,15 +92,32 @@ module.exports = {
92
92
  body: 'var(--color-text-body)',
93
93
  secondary: 'var(--color-text-secondary)',
94
94
  muted: 'var(--color-text-muted)',
95
- // The danger RED as TEXT. Separate from the `danger` brand fill on
96
- // purpose: a fill can be vivid, but text must clear WCAG AA 4.5:1 on
97
- // every surface, and no static red does that on BOTH themes. The token
98
- // is derived per theme by ThemeResolver#contrast_ink.
99
- 'danger-ink': 'var(--color-danger-ink)',
95
+ // The status roles as TEXT. Separate from the success / warning /
96
+ // danger FILLS below on purpose: a fill can be vivid, but text must
97
+ // clear WCAG AA 4.5:1 on every surface, and none of the default role
98
+ // colours does that on BOTH themes (#EF4444, #FF7C47 and #4BAF50 all
99
+ // fail on the light surfaces). Each ink is derived per theme by
100
+ // ThemeResolver#contrast_ink. There is deliberately NO text-danger,
101
+ // text-warning or text-success: a bare role colour as text is the
102
+ // defect, so it stays uncompilable.
103
+ 'danger-ink': 'var(--color-danger-ink)',
104
+ 'warning-ink': 'var(--color-warning-ink)',
105
+ 'success-ink': 'var(--color-success-ink)',
106
+ },
107
+ // The status-role FILLS: badge tints, flash panels, borders. Registered
108
+ // for backgrounds and borders ONLY (not `colors`), so that registering
109
+ // them cannot also mint the text utilities the comment above rules out.
110
+ backgroundColor: {
111
+ success: 'var(--color-success)',
112
+ warning: 'var(--color-warning)',
113
+ danger: 'var(--color-danger)',
100
114
  },
101
115
  borderColor: {
102
116
  subtle: 'var(--color-border)',
103
117
  strong: 'var(--color-border-strong)',
118
+ success: 'var(--color-success)',
119
+ warning: 'var(--color-warning)',
120
+ danger: 'var(--color-danger)',
104
121
  },
105
122
  },
106
123
  },
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.74.8
4
+ version: 0.74.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-10 00:00:00.000000000 Z
11
+ date: 2026-09-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails