studio-engine 0.113.0.rc1 → 0.114.0.rc1
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 +116 -0
- data/README.md +139 -0
- data/app/assets/tailwind/studio_engine/engine.css +52 -0
- data/app/helpers/studio_navbar_helper.rb +47 -2
- data/app/javascript/studio/alpine_scopes.js +9 -3
- data/app/javascript/studio/host_fetchers.js +16 -0
- data/app/javascript/studio/modal_countdown.js +95 -0
- data/app/javascript/studio/newsletter_modal.js +252 -0
- data/app/views/components/_sidebar_panel.html.erb +22 -3
- data/app/views/components/_user_nav.html.erb +37 -4
- data/app/views/layouts/_navbar.html.erb +60 -10
- data/app/views/studio/_alpine_scopes_guard.html.erb +9 -7
- data/app/views/studio/_cropper_assets.html.erb +15 -3
- data/app/views/studio/modals/blocks/_birthday.html.erb +18 -0
- data/app/views/studio/modals/blocks/_change_username.html.erb +39 -1
- data/app/views/studio/modals/blocks/_error_card.html.erb +57 -1
- data/app/views/studio/modals/blocks/_success_card.html.erb +45 -4
- data/app/views/studio/profiles/_newsletter_modals.html.erb +281 -19
- data/app/views/style/_modals.html.erb +65 -0
- data/lib/studio/navbar_account_sidebar.rb +128 -0
- data/lib/studio/navbar_brand.rb +84 -0
- data/lib/studio/navbar_signed_out_cta.rb +77 -8
- data/lib/studio/navbar_slots.rb +36 -14
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +129 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3d6bcb337ac80f3ecf247855862249c6617607ea9dad81b6a2b53e1c907f0d56
|
|
4
|
+
data.tar.gz: 7ab19381f21ddd51bc0c240f9363843132ff073a9742648af7e4d2336ec34d08
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1cb4414413efb70d843277c2138c3a904055ef6a4277d200aba2a271f82b5447af9e5b74496a96d0f6d6113795b98977a9d2c80f3cb213e150d027b881094a40
|
|
7
|
+
data.tar.gz: 859f20f74d5d6a316a0f9bf3b116d810f2b3e25ed026532e271eaab248078ce094f5bd3fb15d85410bfea5993c8997410736cdd69f2ffdf3d88788f328f3299c
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,122 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.114.0 — 2026-10-25
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **The modal blocks carry what a consumer kept its own copies for.** Every new
|
|
12
|
+
local is optional, and unset each block renders byte for byte as in 0.113.0
|
|
13
|
+
(`test/views/modal_blocks_baseline_test.rb` holds the touched blocks to
|
|
14
|
+
goldens rendered by the released 0.113.0 gem). Measured against turf-monster's
|
|
15
|
+
kept modals (its `test/views/engine_modal_readoption_gaps_test.rb`):
|
|
16
|
+
- `studio/profiles/_newsletter_modals` takes `modal_store:` (default
|
|
17
|
+
`"profileModals"`) and `submit_mode: :json`, which posts JSON to the host's
|
|
18
|
+
`subscribe_url:` / `unsubscribe_url:` (`unsubscribe_method:`, default
|
|
19
|
+
`DELETE`) through `studio/newsletter_modal` with a spinner and an inline
|
|
20
|
+
error line, dispatches `changed_event:` (default
|
|
21
|
+
`studio:newsletter-changed`) with `{ subscribed, email, response }`, calls an
|
|
22
|
+
opener's `props.onSuccess`, and steps to the next card with no reload. An
|
|
23
|
+
opener's `props.onSubmit` takes the address instead and nothing is posted.
|
|
24
|
+
Three optional cards: `subscribe_card:` (a consent box), `success_card:`
|
|
25
|
+
(a host slot, `partial:` and `locals:`) and `goodbye_card:`; every card,
|
|
26
|
+
the two existing ones included, takes host copy and an `id:`.
|
|
27
|
+
- `blocks/_birthday` takes `progress_pill: { current:, total: }`, drawn first
|
|
28
|
+
inside the card.
|
|
29
|
+
- `blocks/_error_card` takes `countdown_key:` (an Alpine expression for the
|
|
30
|
+
seconds, a 429's Retry-After), `countdown_text:` and `auto_close:` (default
|
|
31
|
+
true), counted by `studio/modal_countdown`; and `cta_close:`,
|
|
32
|
+
`secondary_close:` and `modal_store:` for buttons that close the store.
|
|
33
|
+
- `blocks/_success_card` takes `cta_close:`, `secondary_close:`,
|
|
34
|
+
`modal_store:`, and `slot_position: :message`, which puts the block in the
|
|
35
|
+
message's place.
|
|
36
|
+
- `blocks/_change_username` takes `locked: { title:, subtitle:, cta_label:,
|
|
37
|
+
cta_href:, dismiss: }`, a card that says why the rename is not open yet.
|
|
38
|
+
|
|
39
|
+
The two factories, `studioModalCountdown` and `studioNewsletterModal`, are
|
|
40
|
+
published by `studio/alpine_scopes` and named in its guard. Every
|
|
41
|
+
`modal_store:` is validated as a JS identifier. The newsletter cards' JSON
|
|
42
|
+
mode uses the motion layer's `.spinner`.
|
|
43
|
+
- **The engine navbar carries what a forking consumer needed.** Measured
|
|
44
|
+
against Turf Monster's forks of `layouts/_navbar`, `components/_user_nav` and
|
|
45
|
+
`components/_sidebar_panel`. Every setting is opt-in, and unset the navbar,
|
|
46
|
+
user nav and sidebar panel render byte-identical to 0.113.0, signed in and
|
|
47
|
+
out, live and preview. README, *Signed-out call to action* through *Brand,
|
|
48
|
+
root hook and layer tier*.
|
|
49
|
+
- `layouts/_navbar` forwards the user nav's slot locals (`balance_slot`,
|
|
50
|
+
`extra_icons_slot`, `div2_slot`, `div2_html`).
|
|
51
|
+
- `Studio.navbar_header_bars`: partials rendered inside the pinned header,
|
|
52
|
+
above its row, in `[data-nav-header-bars]`, so a control bar (an
|
|
53
|
+
impersonation escape) never scrolls away; `engine.css` (*THE IN-HEADER
|
|
54
|
+
BARS*) holds them on the scroll timeline. Never on a preview.
|
|
55
|
+
- `Studio.navbar_signed_out_cta` takes `modal:` (an id, or an id and JSON
|
|
56
|
+
props; the engine builds the `$store.modals.open` expression, and `path:`
|
|
57
|
+
is the fallback `href`), `sign_in_link: false` (no second sign-in link),
|
|
58
|
+
and `sign_in_path:`.
|
|
59
|
+
- `Studio.navbar_account_sidebar`: the account control becomes a button that
|
|
60
|
+
toggles a host store flag, `aria-controls` naming its panels.
|
|
61
|
+
`Studio.navbar_menu_trigger`: a host partial in the admin dropdown's seat,
|
|
62
|
+
in the icon rail and the phone row.
|
|
63
|
+
- `Studio.navbar_phone_name`: the name shows below `md`, capped, and fades
|
|
64
|
+
when it overflows, in CSS only (`engine.css`, *THE PHONE NAME*).
|
|
65
|
+
- `Studio.navbar_brand` (logo, size, alt, split title words),
|
|
66
|
+
`Studio.navbar_root_hook` (`data-navbar-root`) and `Studio.navbar_z_tier`
|
|
67
|
+
(the live header at `z-[var(--z-nav)]` in place of `z-50`).
|
|
68
|
+
- `components/_sidebar_panel` takes `title_html` (a lead slot in the title's
|
|
69
|
+
place that never names the panel) and `close_hook: true`
|
|
70
|
+
(`data-sidebar-panel-close="<id>"` on the close button).
|
|
71
|
+
- Bad static config raises at assignment (`InvalidConfig` or
|
|
72
|
+
`ArgumentError`); a raising callable falls back to the default and is
|
|
73
|
+
reported once.
|
|
74
|
+
|
|
75
|
+
- **`studio/profiles/_newsletter_modals` takes `fetcher:`, the host's own
|
|
76
|
+
request wrapper, in `submit_mode: :json`.** It names an export of the
|
|
77
|
+
importmap module `studio/host_fetchers`, which the engine pins to an empty
|
|
78
|
+
module and a host pins to a file of its own (Turf Monster:
|
|
79
|
+
`export { authedFetch } from "solana_utils"`, so a 401 opens sign-in and a
|
|
80
|
+
429 the rate-limit card). It is the standard's sanctioned seam: a host pin
|
|
81
|
+
of an engine name wins, and a `window.*` global is forbidden as an API
|
|
82
|
+
between files. Validated as a JS identifier and refused under `:form`. A
|
|
83
|
+
fetcher that returns null has answered the request itself; a name the module
|
|
84
|
+
does not export fails the press with the plain line and one console error,
|
|
85
|
+
and never falls back to plain fetch. Unset, the cards send plain fetch, as
|
|
86
|
+
before.
|
|
87
|
+
|
|
88
|
+
### Fixed
|
|
89
|
+
|
|
90
|
+
- **The README warns of three navbar adoption traps** (pinned by
|
|
91
|
+
`test/docs/navbar_readme_warnings_test.rb`): `navbar_menu_trigger` renders
|
|
92
|
+
its partial twice, in the rail and the phone row, so it carries no ids; a
|
|
93
|
+
`navbar_header_bars` callable that raises drops every bar for that render
|
|
94
|
+
(reported once), so one that carries an OPSEC control (Turf Monster's
|
|
95
|
+
impersonation bar, OPSEC-046) must not raise; and `navbar_brand`'s word
|
|
96
|
+
classes live in the host's initializer, which the host's Tailwind scan must
|
|
97
|
+
include.
|
|
98
|
+
- **`Studio.navbar_account_sidebar` refuses `controls:` that name the engine's
|
|
99
|
+
link sidebar** (`studio-link-sidebar`, `studio-link-sidebar-mobile`) with
|
|
100
|
+
`InvalidConfig` at assignment, and a callable's answer when it resolves.
|
|
101
|
+
`studio/link_sidebar` claims any trigger whose `aria-controls` names that
|
|
102
|
+
panel, so the account button would have opened the engine's link sidebar
|
|
103
|
+
instead of the host's.
|
|
104
|
+
- **`Studio.navbar_signed_out_cta` with `modal:` marks its button
|
|
105
|
+
`aria-haspopup="dialog"`,** so a screen reader announces the dialog before
|
|
106
|
+
the press. A CTA without `modal:` renders as before.
|
|
107
|
+
- **`Studio.navbar_phone_name` ends a long name in an ellipsis where scroll
|
|
108
|
+
timelines are unsupported.** The `text-overflow: clip` that makes room for
|
|
109
|
+
the fade now sits inside the `@supports (animation-timeline: scroll())`
|
|
110
|
+
block with the fade, so Firefox, which has no fade, keeps `truncate`'s
|
|
111
|
+
ellipsis instead of cutting the name mid-letter.
|
|
112
|
+
- **`studio/cropper_assets` pins Cropper.js with Subresource Integrity.** The
|
|
113
|
+
cdnjs `cropper.min.css` and `cropper.min.js` (1.6.2) tags now carry sha384
|
|
114
|
+
`integrity` and `crossorigin="anonymous"`, so a changed file on the CDN fails
|
|
115
|
+
to load instead of running on the page. The URLs are unchanged, so a CSP that
|
|
116
|
+
names the script by file (Turf Monster's) keeps matching. The hashes were
|
|
117
|
+
computed over the fetched files and their bytes checked against cdnjs's
|
|
118
|
+
published SRI. Byte-exact copies live in `test/fixtures/files/cropperjs-1.6.2`:
|
|
119
|
+
the suite hashes them offline, and the browser lane serves them for the two
|
|
120
|
+
cdnjs URLs so the tags load as shipped. `bin/cropper-sri-live` re-checks the
|
|
121
|
+
pins against the live CDN.
|
|
122
|
+
|
|
7
123
|
## 0.113.0 — 2026-10-24
|
|
8
124
|
|
|
9
125
|
### Added
|
data/README.md
CHANGED
|
@@ -765,6 +765,51 @@ end
|
|
|
765
765
|
and is reported once per process per error class, never a 500.
|
|
766
766
|
- A host on an older engine can set it safely with
|
|
767
767
|
`Studio.navbar_signed_out_cta = … if Studio.respond_to?(:navbar_signed_out_cta=)`.
|
|
768
|
+
- Three optional keys; absent, the CTA renders as before:
|
|
769
|
+
- `modal:` `"auth"`, or `{ id: "auth", props: { step: "credentials" } }`. The
|
|
770
|
+
button opens that modal on the engine's modal host
|
|
771
|
+
(`$store.modals.open(id, props)`), and `path:` stays its `href`, the
|
|
772
|
+
fallback with no script or no modal host. The engine builds the expression
|
|
773
|
+
from this data: the id must be a plain name and the props JSON, so no host
|
|
774
|
+
JavaScript reaches the page as a string. The button carries
|
|
775
|
+
`data-nav-cta-modal="<id>"` and `aria-haspopup="dialog"`.
|
|
776
|
+
- `sign_in_link: false` drops the quiet sign-in link in both rows, for an app
|
|
777
|
+
whose CTA is its sign-in (one Sign in, not two).
|
|
778
|
+
- `sign_in_path:` points the quiet sign-in link somewhere other than
|
|
779
|
+
`login_path`.
|
|
780
|
+
|
|
781
|
+
```ruby
|
|
782
|
+
config.navbar_signed_out_cta = { label: "Sign in", path: "/signin", sign_in_link: false,
|
|
783
|
+
modal: { id: "auth", props: { step: "credentials", mode: "signup" } } }
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
### Header bars — `Studio.navbar_header_bars`
|
|
787
|
+
|
|
788
|
+
Partials the navbar renders INSIDE its pinned header, above the row, so they
|
|
789
|
+
never scroll away: a control an admin must not lose, such as an impersonation
|
|
790
|
+
bar's JS-free "Return to <admin>" button. An ambient label (the environment
|
|
791
|
+
bar) belongs in the bar stack above the header, which scrolls. Empty (`[]`, the
|
|
792
|
+
default), the navbar renders as before. Rules: `lib/studio/navbar_slots.rb`.
|
|
793
|
+
|
|
794
|
+
```ruby
|
|
795
|
+
config.navbar_header_bars = ->(view) { view.impersonating? ? ["shared/impersonation_banner"] : [] }
|
|
796
|
+
```
|
|
797
|
+
|
|
798
|
+
- They render in `[data-nav-header-bars]`, the header's first child, signed in
|
|
799
|
+
and out, never on a preview.
|
|
800
|
+
- The cost of being in the header: it is a stacking context, so these bars do
|
|
801
|
+
not rise over an open modal the way the bar stack does.
|
|
802
|
+
- On the scroll timeline `engine.css` (*THE IN-HEADER BARS*) carries them down
|
|
803
|
+
by the band, so they hold at the top of the window while the compact bar
|
|
804
|
+
sticks under them.
|
|
805
|
+
- Validation and the raising-callable fallback are `navbar_items`'s.
|
|
806
|
+
- **A callable that raises, the navbar renders no header bars** for that render
|
|
807
|
+
(all of them, not just the one that failed), and the error is reported once
|
|
808
|
+
per process per error class. A bar that carries an OPSEC control (Turf
|
|
809
|
+
Monster's impersonation bar and its "Return to <admin>" button, OPSEC-046)
|
|
810
|
+
would vanish without a trace on the page, so the callable must not raise:
|
|
811
|
+
guard each lookup it makes (`view.impersonating?` on a page whose controller
|
|
812
|
+
lacks the helper, say) and answer `[]` for none.
|
|
768
813
|
|
|
769
814
|
### Navbar items — `Studio.navbar_items`
|
|
770
815
|
|
|
@@ -842,6 +887,100 @@ The legacy string locals (`balance_html`, `extra_icons_html`, `div2_html` —
|
|
|
842
887
|
pre-rendered HTML injected via `raw`) are deprecated but still honored when the
|
|
843
888
|
matching slot is absent, so existing call sites render unchanged.
|
|
844
889
|
|
|
890
|
+
`layouts/_navbar` takes the same four (`balance_slot`, `extra_icons_slot`,
|
|
891
|
+
`div2_slot`, `div2_html`) and forwards them, so an app rendering the engine
|
|
892
|
+
navbar fills these seats without forking either partial:
|
|
893
|
+
|
|
894
|
+
```erb
|
|
895
|
+
<%= render "layouts/navbar", div2_slot: { partial: "components/seeds_bar", locals: { compact: true } } %>
|
|
896
|
+
```
|
|
897
|
+
|
|
898
|
+
A `balance_slot` reserves the balance column as `balance_html` does, and the
|
|
899
|
+
link sidebar's trigger still follows an `extra_icons_slot`.
|
|
900
|
+
|
|
901
|
+
### Menu trigger and account sidebar — `Studio.navbar_menu_trigger`, `Studio.navbar_account_sidebar`
|
|
902
|
+
|
|
903
|
+
For an app whose menu is its own sidebar (Turf Monster's gear sidebar), not
|
|
904
|
+
the admin dropdown or the engine's link sidebar. Both `nil` by default, which
|
|
905
|
+
renders as before.
|
|
906
|
+
|
|
907
|
+
```ruby
|
|
908
|
+
config.navbar_menu_trigger = "components/gear_sidebar_trigger"
|
|
909
|
+
config.navbar_account_sidebar = { flag: "gearOpen", controls: %w[gear-sidebar gear-sidebar-mobile] }
|
|
910
|
+
```
|
|
911
|
+
|
|
912
|
+
- `navbar_menu_trigger` is one partial spec (or a callable) rendered in the
|
|
913
|
+
admin dropdown's seat: the icon rail from `md` (in
|
|
914
|
+
`[data-nav-menu-trigger]`), and the phone row. Rules:
|
|
915
|
+
`lib/studio/navbar_slots.rb`. **It renders the partial twice**, once in each
|
|
916
|
+
seat, and both are in the page at every width, so the partial carries no
|
|
917
|
+
`id` (nor anything keyed to one, such as a `<label for>`): a second copy of
|
|
918
|
+
an id is invalid, and `getElementById` and `aria-controls` find only the
|
|
919
|
+
first. Point at the panel by its own id from the trigger, never the reverse.
|
|
920
|
+
- `navbar_account_sidebar` makes the account control a button that toggles
|
|
921
|
+
`$store.<store>.<flag>` (`store:` defaults to `"sidebars"`), with
|
|
922
|
+
`aria-controls` naming the panels, `aria-haspopup="dialog"` and a bound
|
|
923
|
+
`aria-expanded`. It wins over the link sidebar and the account link. The
|
|
924
|
+
engine builds the toggle from the names, which must be plain identifiers. It
|
|
925
|
+
carries `data-profile-image-toggle`, so a host handler that claims its
|
|
926
|
+
triggers in the capture phase, before Alpine binds, claims it too. Its
|
|
927
|
+
`controls:` may not name the engine's own link sidebar
|
|
928
|
+
(`studio-link-sidebar`, `studio-link-sidebar-mobile`): `studio/link_sidebar`
|
|
929
|
+
claims a click on any trigger whose `aria-controls` names it, so the button
|
|
930
|
+
would open the engine's panel instead of the host's. That is refused with
|
|
931
|
+
`InvalidConfig` at assignment (a callable's answer, when it resolves). Rules:
|
|
932
|
+
`lib/studio/navbar_account_sidebar.rb`.
|
|
933
|
+
|
|
934
|
+
### Sidebar panel lead and close hook
|
|
935
|
+
|
|
936
|
+
`components/_sidebar_panel` takes two opt-in locals; unset, the panel renders
|
|
937
|
+
as before:
|
|
938
|
+
|
|
939
|
+
- `title_html:` HTML in the title's place, for a panel whose header leads with
|
|
940
|
+
a live status line. It is content, not a name: it never feeds
|
|
941
|
+
`aria-labelledby` or `aria-label`, so a caller that passes it instead of
|
|
942
|
+
`title:` passes `aria_label:`. `title:` wins when both are passed.
|
|
943
|
+
- `close_hook: true` (with an `id:`) adds `data-sidebar-panel-close="<id>"` to
|
|
944
|
+
the close button, beside `data-link-sidebar-close`. The contract is the
|
|
945
|
+
host's: its own document handler matches the button to its panel by id
|
|
946
|
+
(Turf Monster's gear sidebar does). `studio/link_sidebar` reads only
|
|
947
|
+
`data-link-sidebar-close`, and only inside its own panels.
|
|
948
|
+
|
|
949
|
+
### Phone name — `Studio.navbar_phone_name`
|
|
950
|
+
|
|
951
|
+
`true` shows the signed-in name beside the avatar below `md` too, capped at
|
|
952
|
+
4.5rem (6rem from 400px), and fades it out over its right half when it does not
|
|
953
|
+
fit. The fade is CSS only (`engine.css`, *THE PHONE NAME*): it runs on the
|
|
954
|
+
name's own scroll timeline, which is inactive while the name fits, so a short
|
|
955
|
+
name never fades. A browser without scroll timelines (Firefox today) ends a long
|
|
956
|
+
name in an ellipsis instead. `false`, the default, keeps the name
|
|
957
|
+
screen-reader text below `md`.
|
|
958
|
+
|
|
959
|
+
### Brand, root hook and layer tier — `Studio.navbar_brand`, `Studio.navbar_root_hook`, `Studio.navbar_z_tier`
|
|
960
|
+
|
|
961
|
+
```ruby
|
|
962
|
+
config.navbar_brand = { logo: "/icon-192.png", logo_size: 48, alt: "Turf Monster",
|
|
963
|
+
words: [{ text: "Turf" }, { text: "Monster", class: "tm-wordmark" }] }
|
|
964
|
+
config.navbar_root_hook = true # data-navbar-root on the header, live and preview
|
|
965
|
+
config.navbar_z_tier = true # the live header at z-[var(--z-nav)], not z-50
|
|
966
|
+
```
|
|
967
|
+
|
|
968
|
+
- `navbar_brand`: every key optional. `logo:` replaces the "Navbar Logo" theme
|
|
969
|
+
image, `logo_size:` adds `width` and `height`, `alt:` replaces `app_name` as
|
|
970
|
+
its alt, and `words:` replaces the `app_name` split: each word its own
|
|
971
|
+
truncating span with the host's class and nothing else. `navbar_wordmark`
|
|
972
|
+
wins when both are set. Rules: `lib/studio/navbar_brand.rb`.
|
|
973
|
+
- **The word classes live in the host's initializer**
|
|
974
|
+
(`config/initializers/studio.rb`), which a host's Tailwind scan does not
|
|
975
|
+
read by default (Turf Monster's `content:` lists `app/views`,
|
|
976
|
+
`app/helpers`, `app/javascript` and the engine's views). A utility named
|
|
977
|
+
only there, `text-heading` say, compiles only if some view also uses it.
|
|
978
|
+
Add the initializer to the scan (`content:`, or `@source` under Tailwind 4),
|
|
979
|
+
or give the word a class the host's CSS defines (`tm-wordmark`). The same
|
|
980
|
+
rule as the engine's views glob:
|
|
981
|
+
[NEW_APP_SETUP.md, section 14](docs/NEW_APP_SETUP.md#14-css-component-classes).
|
|
982
|
+
- `navbar_root_hook` and `navbar_z_tier` are booleans, `false` by default.
|
|
983
|
+
|
|
845
984
|
### The account row
|
|
846
985
|
|
|
847
986
|
From 768px the user nav's right end reads theme toggle, gear, name, avatar,
|
|
@@ -883,6 +883,58 @@ body.modal-open .studio-app-banner {
|
|
|
883
883
|
}
|
|
884
884
|
}
|
|
885
885
|
}
|
|
886
|
+
/* THE PHONE NAME (Studio.navbar_phone_name, components/_user_nav). Below 768px
|
|
887
|
+
the signed-in name shows beside the avatar in a capped box. A name that
|
|
888
|
+
fits draws whole; one that does not fades out over its right half instead
|
|
889
|
+
of ending in an ellipsis. The fade needs no script: the name's box clips
|
|
890
|
+
(truncate), which makes it a scroll container, and a scroll timeline on a
|
|
891
|
+
container with nothing to scroll is inactive, so the animation, and its
|
|
892
|
+
mask, applies only while the name overflows. The clip that drops the
|
|
893
|
+
ellipsis lives inside the same check: a browser without scroll timelines
|
|
894
|
+
has no fade, so it keeps truncate's ellipsis rather than cutting the name
|
|
895
|
+
mid-letter. From 768px the name truncates as before. */
|
|
896
|
+
@media (max-width: 767px) {
|
|
897
|
+
@supports (animation-timeline: scroll()) {
|
|
898
|
+
[data-nav-name-phone] > [data-nav-name] {
|
|
899
|
+
text-overflow: clip;
|
|
900
|
+
animation: nav-name-fade linear both;
|
|
901
|
+
animation-timeline: scroll(self inline);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
@keyframes nav-name-fade {
|
|
906
|
+
from, to {
|
|
907
|
+
-webkit-mask-image: linear-gradient(to right, black 50%, transparent);
|
|
908
|
+
mask-image: linear-gradient(to right, black 50%, transparent);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/* THE IN-HEADER BARS ON THE SCROLL TIMELINE (Studio.navbar_header_bars, which
|
|
913
|
+
layouts/_navbar renders in [data-nav-header-bars] at the top of the header).
|
|
914
|
+
On the timeline the header sticks at minus its band, so its TOP band leaves
|
|
915
|
+
the window, and these bars sit at its top. So they move down by the band
|
|
916
|
+
over the range the band scrolls away in, on the same linear timeline: the
|
|
917
|
+
header rises by exactly as much as they fall, and they hold still at the top
|
|
918
|
+
of the window. The band needs no correction (the controller measures it as
|
|
919
|
+
expanded less collapsed height, and the bars are in both readings), nor
|
|
920
|
+
does the row (it slides down half the band, which centres it on what is left
|
|
921
|
+
below the bars). Same conditions as the collapse above. Lifted from Turf
|
|
922
|
+
Monster's fork of the navbar, where it was measured. */
|
|
923
|
+
@layer utilities {
|
|
924
|
+
@supports (animation-timeline: scroll()) {
|
|
925
|
+
@media (min-width: 768px) {
|
|
926
|
+
.nav-shell[data-nav-timeline] > [data-nav-header-bars] {
|
|
927
|
+
position: relative;
|
|
928
|
+
z-index: 1;
|
|
929
|
+
animation: nav-tl-header-bars linear both;
|
|
930
|
+
animation-timeline: scroll(root block);
|
|
931
|
+
animation-range: var(--nav-start, 0px) calc(var(--nav-start, 0px) + var(--nav-band));
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
@keyframes nav-tl-header-bars { to { transform: translateY(var(--nav-band)); } }
|
|
937
|
+
|
|
886
938
|
/* THE PINNED EDGE ON THE SCROLL TIMELINE (studio/pinned_stack).
|
|
887
939
|
On a timeline header the pinned stack publishes where the stack sits once it
|
|
888
940
|
has PINNED (the compact bar), and writes nothing per scroll frame. A sticky
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# The navbar's view seams: links, items, the avatar badge
|
|
4
|
-
# CTA. Each resolves once per render pass, since a host lambda may read
|
|
3
|
+
# The navbar's view seams: links, items, the avatar badge, the header bars,
|
|
4
|
+
# the menu trigger, the account sidebar and the signed-out CTA. Each resolves once per render pass, since a host lambda may read
|
|
5
5
|
# current_user or the database.
|
|
6
6
|
module StudioNavbarHelper
|
|
7
7
|
NAVBAR_LINK_CLASS = "inline-flex items-center gap-1.5 whitespace-nowrap font-medium transition"
|
|
@@ -21,6 +21,22 @@ module StudioNavbarHelper
|
|
|
21
21
|
@studio_navbar_signed_out_cta = Studio.navbar_signed_out_cta_for(self)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
+
# The signed-out CTA's modal opener, as link_to options: {} when the CTA
|
|
25
|
+
# names no modal, so the button renders as before. The expression is built
|
|
26
|
+
# here from validated data (a plain id and JSON props), never taken from the
|
|
27
|
+
# host as a string. It opens the modal only where the engine's modal host
|
|
28
|
+
# has registered $store.modals, and only then cancels the link, so the href
|
|
29
|
+
# is the fallback without script or without a host. aria-haspopup="dialog"
|
|
30
|
+
# tells a screen reader, before the press, that the link opens a dialog.
|
|
31
|
+
def studio_navbar_cta_modal_options(cta)
|
|
32
|
+
modal = cta && cta[:modal]
|
|
33
|
+
return {} unless modal
|
|
34
|
+
|
|
35
|
+
open = "$store.modals.open(#{modal[:id].to_json}, #{modal[:props].to_json})"
|
|
36
|
+
{ "data-nav-cta-modal": modal[:id], "aria-haspopup": "dialog",
|
|
37
|
+
"@click": "$store.modals && ($event.preventDefault(), #{open})" }
|
|
38
|
+
end
|
|
39
|
+
|
|
24
40
|
# Studio.navbar_items as partial specs ({ partial:, locals: }), [] for none.
|
|
25
41
|
# Resolved once per render pass: the desktop bar and the phone row both ask.
|
|
26
42
|
def studio_navbar_items
|
|
@@ -34,6 +50,35 @@ module StudioNavbarHelper
|
|
|
34
50
|
@studio_navbar_avatar_badge = Studio.navbar_avatar_badge_for(self)
|
|
35
51
|
end
|
|
36
52
|
|
|
53
|
+
# Studio.navbar_header_bars as partial specs, [] for none.
|
|
54
|
+
def studio_navbar_header_bars
|
|
55
|
+
@studio_navbar_header_bars ||= Studio.navbar_header_bars_for(self)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Studio.navbar_menu_trigger as one partial spec, or nil for the admin
|
|
59
|
+
# dropdown. Resolved once per render pass: the rail and the phone row both ask.
|
|
60
|
+
def studio_navbar_menu_trigger
|
|
61
|
+
return @studio_navbar_menu_trigger if defined?(@studio_navbar_menu_trigger)
|
|
62
|
+
|
|
63
|
+
@studio_navbar_menu_trigger = Studio.navbar_menu_trigger_for(self)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Studio.navbar_account_sidebar as { store:, flag:, controls: }, or nil.
|
|
67
|
+
def studio_navbar_account_sidebar
|
|
68
|
+
return @studio_navbar_account_sidebar if defined?(@studio_navbar_account_sidebar)
|
|
69
|
+
|
|
70
|
+
@studio_navbar_account_sidebar = Studio.navbar_account_sidebar_for(self)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The account control's toggle and aria-expanded expressions for a resolved
|
|
74
|
+
# account sidebar, built from its validated names. The toggle is guarded, so
|
|
75
|
+
# a page without the store does nothing rather than throw.
|
|
76
|
+
def studio_navbar_account_sidebar_expressions(sidebar)
|
|
77
|
+
ref = "$store.#{sidebar[:store]}"
|
|
78
|
+
{ toggle: "#{ref} && (#{ref}.#{sidebar[:flag]} = !#{ref}.#{sidebar[:flag]})",
|
|
79
|
+
expanded: "#{ref} && #{ref}.#{sidebar[:flag]} ? 'true' : 'false'" }
|
|
80
|
+
end
|
|
81
|
+
|
|
37
82
|
# One resolved partial spec, rendered through the normal partial pipeline.
|
|
38
83
|
def studio_navbar_slot_tag(spec)
|
|
39
84
|
render partial: spec[:partial], locals: spec[:locals]
|
|
@@ -7,13 +7,15 @@
|
|
|
7
7
|
// x-data="birthdayModal({ minAge, url })" studio/birthday
|
|
8
8
|
// x-data="studioBirthdayFields('1991-01-31')" studio/birthday
|
|
9
9
|
// x-data="studioProfileForm({ first_name: 'Pat' })" studio/profile_form
|
|
10
|
+
// x-data="studioModalCountdown({ seconds: 12 })" studio/modal_countdown
|
|
11
|
+
// x-data="studioNewsletterModal({ url: '/n' })" studio/newsletter_modal
|
|
10
12
|
// submitFormWithProgress(form, opts) studio/image_upload
|
|
11
13
|
//
|
|
12
14
|
// WHY THESE ARRIVE WITH THE PAGE AND ARE NOT LAZY. Alpine evaluates an x-data
|
|
13
15
|
// expression when it meets the element: at start for a page's own markup, at
|
|
14
16
|
// mount for a modal card. A factory that is not defined by then leaves its
|
|
15
|
-
// element dead, and the element here is a photo upload, the birthday gate
|
|
16
|
-
// the profile form. A dynamic import cannot promise to have resolved, and one
|
|
17
|
+
// element dead, and the element here is a photo upload, the birthday gate,
|
|
18
|
+
// the profile form, a rate-limit card's countdown or a newsletter card. A dynamic import cannot promise to have resolved, and one
|
|
17
19
|
// that fails is final for the document (studio/lazy_controllers). So the
|
|
18
20
|
// factories are imported statically and preloaded on every page.
|
|
19
21
|
//
|
|
@@ -37,6 +39,8 @@ import { cropPhotoModal } from "studio/cropper"
|
|
|
37
39
|
import { imageUploadHost, avatarCropperHost, submitFormWithProgress } from "studio/image_upload"
|
|
38
40
|
import { birthdayModal, studioBirthdayFields } from "studio/birthday"
|
|
39
41
|
import { studioProfileForm } from "studio/profile_form"
|
|
42
|
+
import { studioModalCountdown } from "studio/modal_countdown"
|
|
43
|
+
import { studioNewsletterModal } from "studio/newsletter_modal"
|
|
40
44
|
|
|
41
45
|
// name -> the factory Alpine calls. Alpine passes the expression's own
|
|
42
46
|
// arguments and nothing else.
|
|
@@ -46,7 +50,9 @@ export const SCOPES = {
|
|
|
46
50
|
avatarCropperHost: () => avatarCropperHost(),
|
|
47
51
|
birthdayModal: (opts) => birthdayModal(opts),
|
|
48
52
|
studioBirthdayFields: (initial) => studioBirthdayFields(initial),
|
|
49
|
-
studioProfileForm: (initial) => studioProfileForm(initial)
|
|
53
|
+
studioProfileForm: (initial) => studioProfileForm(initial),
|
|
54
|
+
studioModalCountdown: (opts) => studioModalCountdown(opts),
|
|
55
|
+
studioNewsletterModal: (opts) => studioNewsletterModal(opts)
|
|
50
56
|
}
|
|
51
57
|
|
|
52
58
|
// Plain functions a page calls by name.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// studio/host_fetchers: the seam a host fills with its own request wrappers.
|
|
2
|
+
//
|
|
3
|
+
// The engine pins this name to this empty module. A host that sends its
|
|
4
|
+
// requests through a wrapper of its own (Turf Monster's authedFetch) pins the
|
|
5
|
+
// same name to a file of its own, which wins because the host's importmap is
|
|
6
|
+
// drawn last, and exports the wrapper by name:
|
|
7
|
+
//
|
|
8
|
+
// # config/importmap.rb
|
|
9
|
+
// pin "studio/host_fetchers", to: "newsletter_fetchers.js"
|
|
10
|
+
// // app/javascript/newsletter_fetchers.js
|
|
11
|
+
// export { authedFetch } from "solana_utils"
|
|
12
|
+
//
|
|
13
|
+
// A partial that takes a fetcher: local (studio/profiles/_newsletter_modals)
|
|
14
|
+
// names one of those exports; studio/newsletter_modal imports this module on
|
|
15
|
+
// the first press that needs it. Nothing here is on the boot graph.
|
|
16
|
+
export {}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// studio/modal_countdown: a modal card that counts down and closes itself.
|
|
2
|
+
//
|
|
3
|
+
// x-data="studioModalCountdown({ seconds: $store.modals.current().props.secondsLeft, store: 'modals', autoClose: true })"
|
|
4
|
+
// blocks/_error_card with countdown_key: the "give it a sec" wait after
|
|
5
|
+
// a 429, seeded from the response's Retry-After. The card shows
|
|
6
|
+
// waitLabel and, at zero, closes the store it is mounted in.
|
|
7
|
+
//
|
|
8
|
+
// The seconds are read ONCE, at mount, from whatever expression the host named:
|
|
9
|
+
// the card counts its own copy down and never writes back to the modal's props.
|
|
10
|
+
// No seconds, or none above zero, is no countdown: nothing ticks and nothing
|
|
11
|
+
// closes, so a card opened without a Retry-After stays until it is dismissed.
|
|
12
|
+
//
|
|
13
|
+
// studio/alpine_scopes publishes the factory on window, statically: Alpine
|
|
14
|
+
// evaluates the x-data as the modal host mounts the card, and a factory that is
|
|
15
|
+
// not defined by then leaves the card dead. It imports nothing;
|
|
16
|
+
// test/javascript/modal_countdown.test.mjs loads it as a data: module.
|
|
17
|
+
|
|
18
|
+
// Above this many seconds the wait reads in minutes ("3 min"), below it in
|
|
19
|
+
// seconds ("45s"): a rate limit of several minutes counted in seconds reads as
|
|
20
|
+
// a clock, not a wait.
|
|
21
|
+
export const MINUTES_FROM = 90
|
|
22
|
+
|
|
23
|
+
// A host value as whole seconds to wait: a Retry-After of "12", 12 or 11.2 all
|
|
24
|
+
// wait 12. Anything that is not a positive number is 0, no wait.
|
|
25
|
+
export function startSeconds(value) {
|
|
26
|
+
const n = Number(value)
|
|
27
|
+
return Number.isFinite(n) && n > 0 ? Math.ceil(n) : 0
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The wait as a person reads it.
|
|
31
|
+
export function waitLabel(seconds) {
|
|
32
|
+
const s = Math.max(0, Math.floor(Number(seconds) || 0))
|
|
33
|
+
return s > MINUTES_FROM ? Math.ceil(s / 60) + " min" : s + "s"
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// x-data for a counting card.
|
|
37
|
+
//
|
|
38
|
+
// opts:
|
|
39
|
+
// seconds the wait, read once at mount (see startSeconds).
|
|
40
|
+
// store the Alpine store the card is mounted in (default "modals").
|
|
41
|
+
// autoClose close that store at zero (default true).
|
|
42
|
+
//
|
|
43
|
+
// env: { win, setInterval, clearInterval } for the tests.
|
|
44
|
+
export function studioModalCountdown(opts, env) {
|
|
45
|
+
opts = opts || {}
|
|
46
|
+
const win = (env && env.win) || (typeof window !== "undefined" ? window : {})
|
|
47
|
+
const every = (env && env.setInterval) || ((fn, ms) => setInterval(fn, ms))
|
|
48
|
+
const stopEvery = (env && env.clearInterval) || ((id) => clearInterval(id))
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
remaining: startSeconds(opts.seconds),
|
|
52
|
+
store: opts.store || "modals",
|
|
53
|
+
autoClose: opts.autoClose !== false,
|
|
54
|
+
_timer: null,
|
|
55
|
+
|
|
56
|
+
get counting() {
|
|
57
|
+
return this.remaining > 0
|
|
58
|
+
},
|
|
59
|
+
get waitLabel() {
|
|
60
|
+
return waitLabel(this.remaining)
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
init() {
|
|
64
|
+
this._stop()
|
|
65
|
+
if (this.remaining <= 0) return
|
|
66
|
+
this._timer = every(() => this.tick(), 1000)
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
tick() {
|
|
70
|
+
if (this.remaining <= 0) return this._stop()
|
|
71
|
+
this.remaining -= 1
|
|
72
|
+
if (this.remaining > 0) return
|
|
73
|
+
this.remaining = 0
|
|
74
|
+
this._stop()
|
|
75
|
+
if (this.autoClose) this._close()
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
// The card may already be gone (a person dismissed it in the last second),
|
|
79
|
+
// so a store with nothing open, or no such store, is not an error.
|
|
80
|
+
_close() {
|
|
81
|
+
try { win.Alpine.store(this.store).close() } catch (_) {}
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
_stop() {
|
|
85
|
+
if (this._timer !== null) stopEvery(this._timer)
|
|
86
|
+
this._timer = null
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
// Alpine calls destroy() when the host unmounts the card, so a card closed
|
|
90
|
+
// by hand leaves no interval behind it.
|
|
91
|
+
destroy() {
|
|
92
|
+
this._stop()
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|