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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4fdd2e298951ce853f0cccdf203c26d3e060c7de30c3541d4fb07bb00310e167
4
- data.tar.gz: 1fecbb7d380d3d80c000d08b54a70b8407962ef0495cb7d41b5579f8a390673d
3
+ metadata.gz: 3d6bcb337ac80f3ecf247855862249c6617607ea9dad81b6a2b53e1c907f0d56
4
+ data.tar.gz: 7ab19381f21ddd51bc0c240f9363843132ff073a9742648af7e4d2336ec34d08
5
5
  SHA512:
6
- metadata.gz: 6c7e8e894beb7fd5bdcfd73a83e459a244fa7f2e858923382f9ba378e64b764e9c9f1adb7e22a633e22a7fb937d98990ce8056a1f69d6265a911684e71229b31
7
- data.tar.gz: 1bc3712f6953dbb9ca9d3c2ce6bb4713a1c9ed99897864d5f6381c9b5e87edf15e8c4fb591864af48ccb02af45b80c39e0843a0da00528c8b13ff268033ce7a0
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 and the signed-out
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 or
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
+ }