studio-engine 0.74.9 → 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 +95 -0
- data/README.md +56 -46
- data/app/assets/tailwind/studio_engine/engine-motion.css +44 -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/_scoped_host.html.erb +7 -6
- data/app/views/studio/modals/blocks/_age_gate.html.erb +8 -4
- data/app/views/studio/modals/blocks/_entry_confirmed.html.erb +2 -1
- data/app/views/studio/modals/onboarding/_first_name.html.erb +8 -7
- 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
|
@@ -4,6 +4,8 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.74.10 — 2026-09-13
|
|
8
|
+
|
|
7
9
|
### Fixed
|
|
8
10
|
|
|
9
11
|
- **The auth resend footer's error line failed WCAG AA in both themes; it now
|
|
@@ -17,9 +19,102 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
17
19
|
turf-monster's currentColor ring (`--spinner-track: currentColor;
|
|
18
20
|
--spinner-color: transparent`), so a host that drops its fork sees no change.
|
|
19
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.
|
|
20
93
|
|
|
21
94
|
### Docs
|
|
22
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
|
+
|
|
23
118
|
- **Thirty-five minor versions of shipped entries left `## Unreleased` and moved
|
|
24
119
|
under the version that actually shipped them.** The block spanned 2,382 lines and every
|
|
25
120
|
entry in it had already been published — `accepted` sat on the `v0.74.4` release
|
data/README.md
CHANGED
|
@@ -282,8 +282,9 @@ a single-quoted JS string — the shape rule 2 steers you to — has a second wa
|
|
|
282
282
|
out: a `'`. ERB escapes it to `'`, the parser decodes that straight back
|
|
283
283
|
into the value, and the bare `'` ends the string: the same
|
|
284
284
|
`Alpine Expression Error`. Route such a value through
|
|
285
|
-
`Studio::JsLiteral.in_attribute(value)
|
|
286
|
-
|
|
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
|
|
287
288
|
position (`Studio::JsIdentifier`) included.
|
|
288
289
|
|
|
289
290
|
These are properties of the HOST, not of any one card, and they apply to every
|
|
@@ -296,8 +297,8 @@ partial explaining its own root. Someone writing a NEW partial has no specimen
|
|
|
296
297
|
to read; they read the host's documentation, so that is where the rules have
|
|
297
298
|
to live.
|
|
298
299
|
|
|
299
|
-
And a specimen could not
|
|
300
|
-
it: the style guide wraps EVERY registration's partial in a `<div>` of its own
|
|
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
|
|
301
302
|
— `<template x-if="…"><div><%= render … %></div></template>` — and that
|
|
302
303
|
wrapper supplies the single root the rule is about. A specimen that broke the
|
|
303
304
|
rule would still render correctly in the guide, so the specimens cannot
|
|
@@ -324,53 +325,59 @@ What the engine **keeps** is the SESSION half of Solana sign-in:
|
|
|
324
325
|
`Studio.wallet_sign_in_statement` — the single source for the signed statement,
|
|
325
326
|
which the gem's deep link reads so the two cannot drift.
|
|
326
327
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
The
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
renders whatever it finds:
|
|
335
|
-
|
|
336
|
-
solana_studio/auth/_wallet_credential (shipped by the solana-studio gem)
|
|
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.
|
|
337
335
|
|
|
338
|
-
|
|
339
|
-
config flag; an app without that layer renders nothing there, so a web2 app
|
|
340
|
-
carries no wallet markup at all rather than markup hidden behind a flag. This is
|
|
341
|
-
the same optional-partial convention as `modals/_host_extras` above, and it uses
|
|
342
|
-
the same three-term `lookup_context.exists?`.
|
|
343
|
-
|
|
344
|
-
Copying the auth modal into the gem was rejected deliberately: it would fork a
|
|
345
|
-
surface both apps sign in through, and the two copies would drift — which is how
|
|
346
|
-
the wallet picker reached three copies before it was promoted.
|
|
336
|
+
#### The auth modal's credential slot
|
|
347
337
|
|
|
348
|
-
**
|
|
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:
|
|
349
359
|
|
|
350
360
|
| Question | Answered by | Where it is decided |
|
|
351
361
|
|---|---|---|
|
|
352
|
-
| 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 |
|
|
353
363
|
| Should it **show**? | `methodOn('wallet')`, falling back to `Studio.auth_method?(:wallet) && Studio.feature?(:web3)` | Alpine, inside the contributed partial — gates visibility |
|
|
354
364
|
|
|
355
365
|
Both terms of the Ruby gate are load-bearing, and they fail differently. Without
|
|
356
366
|
the **registration** term a layer that ships the credential but not the picker
|
|
357
367
|
draws a button that opens an empty panel — not hypothetical, solana-studio 0.5.2
|
|
358
|
-
shipped exactly that pair. Without the **existence** term
|
|
368
|
+
shipped exactly that pair. Without the **existence** term a card whose picker
|
|
359
369
|
resolves but whose credential does not raises `Missing partial` in front of
|
|
360
|
-
someone signing in, instead of quietly rendering no button.
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
`
|
|
364
|
-
button from the DOM on a web3-off app that bundles the gem — and the "or"
|
|
365
|
-
divider reads `methodOn('wallet')` too, so ticking Solana Wallet on the style
|
|
366
|
-
guide's Sign in card would then float a divider above a button that is not
|
|
367
|
-
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.
|
|
368
374
|
|
|
369
|
-
|
|
375
|
+
The partial renders inside the hosting card's Alpine scope, so it may use
|
|
370
376
|
`methodOn(...)`, `attested()` (the legal-age gate — call it, or wallet becomes
|
|
371
377
|
the one credential that skips attestation) and `props.submitting`. It receives
|
|
372
|
-
one local, `modal_store
|
|
373
|
-
|
|
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.
|
|
374
381
|
|
|
375
382
|
**Name a store like a JavaScript identifier.** Partials splice this local in as a
|
|
376
383
|
bare name — `$store.<name>.current()` — rather than as a string, so a value
|
|
@@ -379,10 +386,11 @@ SyntaxError in the whole `x-data`, and Alpine mounts a component that renders
|
|
|
379
386
|
every element and does nothing. Escaping is not the repair (an escaped identifier
|
|
380
387
|
is a different SyntaxError); a name that matches `/\A[A-Za-z_$][A-Za-z0-9_$]*\z/`
|
|
381
388
|
is. `studio/modals/onboarding/_first_name` enforces exactly that and raises
|
|
382
|
-
`ArgumentError` on anything else
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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.
|
|
386
394
|
|
|
387
395
|
It also keeps two blocks the gem renders **by name** across the gem boundary:
|
|
388
396
|
`studio/modals/blocks/wallet_brand_sprite` and `studio/modals/blocks/card_header`.
|
|
@@ -409,11 +417,13 @@ default to `"modals"`, so existing call sites are unchanged. The engine's
|
|
|
409
417
|
|
|
410
418
|
Two things that will bite you:
|
|
411
419
|
|
|
412
|
-
- **Render `scoped_host`, not `host`.**
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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.
|
|
417
427
|
- **Guard registrations with `current()?.id`.** The outer template unmounts one
|
|
418
428
|
tick *after* the stack empties, so a bare `.id` throws on every close.
|
|
419
429
|
|
|
@@ -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
|
|
@@ -1872,4 +1873,44 @@ body.modal-open { overflow: visible; }
|
|
|
1872
1873
|
transition: none !important;
|
|
1873
1874
|
}
|
|
1874
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
|
+
}
|
|
1875
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 %>' })">
|
|
@@ -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:
|
|
@@ -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
|
},
|
|
@@ -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")
|
|
@@ -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
|
|
@@ -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
|