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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +133 -3
- data/README.md +130 -74
- data/app/assets/tailwind/studio_engine/engine-motion.css +51 -3
- data/app/views/components/_avatar_cropper.html.erb +1 -1
- data/app/views/error_logs/index.html.erb +1 -1
- data/app/views/schema/index.html.erb +2 -2
- data/app/views/sessions/new.html.erb +1 -1
- data/app/views/solana_sessions/phantom_callback.html.erb +39 -1
- data/app/views/studio/email_images/index.html.erb +1 -1
- data/app/views/studio/emails/index.html.erb +4 -3
- data/app/views/studio/emails/orphan.html.erb +1 -1
- data/app/views/studio/emails/show.html.erb +8 -7
- data/app/views/studio/modals/_crop_photo.html.erb +3 -2
- data/app/views/studio/modals/_host.html.erb +5 -1
- data/app/views/studio/modals/_scoped_host.html.erb +13 -11
- data/app/views/studio/modals/auth/_resend_footer.html.erb +26 -2
- data/app/views/studio/modals/blocks/_age_gate.html.erb +8 -4
- data/app/views/studio/modals/blocks/_birthday.html.erb +1 -1
- data/app/views/studio/modals/blocks/_entry_confirmed.html.erb +2 -1
- data/app/views/studio/modals/blocks/_leveling_activity.html.erb +1 -1
- data/app/views/studio/modals/onboarding/_first_name.html.erb +9 -8
- data/app/views/style/modals/_newsletter_email.html.erb +2 -1
- data/app/views/style/modals/_unsubscribe_confirm.html.erb +2 -1
- data/lib/studio/color_scale.rb +6 -0
- data/lib/studio/js_identifier.rb +3 -2
- data/lib/studio/js_literal.rb +3 -1
- data/lib/studio/theme_resolver.rb +47 -6
- data/lib/studio/version.rb +1 -1
- data/tailwind/studio.tailwind.config.js +22 -5
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 91281f58c1fc5ccba013b728eaaa3fb8b8e1fc40b451e45893b832c83fc02151
|
|
4
|
+
data.tar.gz: 39ffd06a4758929bbd1af758d17b631596f3b9a4e8cf8df8b54c67c49a0bb8ee
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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`
|
|
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
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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 `"`, 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 `'`, 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.
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
The
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
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
|
|
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
|
|
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
|
-
|
|
320
|
-
`
|
|
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
|
-
|
|
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
|
|
330
|
-
|
|
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
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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`.**
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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
|
|
78
|
-
(`.studio-border-glow`, `.loading-dots`) so the follow-up
|
|
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-
|
|
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
|
-
|
|
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" ? "
|
|
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 —
|
|
19
|
-
|
|
20
|
-
partial
|
|
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
|
|
511
|
-
|
|
512
|
-
|
|
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
|
|
32
|
-
|
|
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
|
|
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.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
45
|
-
fail
|
|
46
|
-
one) and NO double quote inside x-data (it closes the
|
|
47
|
-
mounts a component that renders everything and does
|
|
48
|
-
|
|
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"
|
|
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-
|
|
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
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
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 & Continue</span>
|
|
133
133
|
<span x-show="submitting" class="inline-flex items-center justify-center gap-2" style="display: none;">
|
|
134
|
-
<span class="
|
|
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
|
|
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="
|
|
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
|
|
87
|
-
|
|
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
|
|
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
|
|
150
|
-
#
|
|
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
|
|
188
|
-
#
|
|
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="
|
|
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
|
|
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
|
|
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,
|
data/lib/studio/color_scale.rb
CHANGED
|
@@ -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})"
|
data/lib/studio/js_identifier.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
|
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
|
data/lib/studio/js_literal.rb
CHANGED
|
@@ -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
|
|
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" =>
|
|
73
|
-
"--color-warning" =>
|
|
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:
|
|
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" =>
|
|
200
|
-
"--color-warning" =>
|
|
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:
|
|
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
|
data/lib/studio/version.rb
CHANGED
|
@@ -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
|
|
96
|
-
// purpose: a fill can be vivid, but text must
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
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.
|
|
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-
|
|
11
|
+
date: 2026-09-13 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|