studio-engine 0.143.2 → 0.144.0

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: d143f0f1859ecc3107721013965ab60cfdc991f7bee318fee274361278e98cc1
4
- data.tar.gz: 32c057de7990555e9a8ff3a54963ba6916e0bba30fc26489a46c783def9fb5f8
3
+ metadata.gz: 98d7d512ad7dbca4f6610b95f82ea7c0975450f9150522a4a68e89df5f392bf5
4
+ data.tar.gz: 8b2e116b30cc9a8d7d927a1f1bfd1eb155fc02d7cbbbbc0f6dccd4f33b41446e
5
5
  SHA512:
6
- metadata.gz: d0857709512f2a2180b6d1c25aeb9c886ba6708550cc00560243d5881d0422e900c77145823e31018aab7d334133deb8087fc3cbcaebfa79a5ef332ce15ecef1
7
- data.tar.gz: d500cb012588520e9c8d76974f9a5f8189560288216b584a6712a609393b0dbed749e10bf1b33bb590932dbfc4cadf632a86ad448053dafa476fbb956a78128b
6
+ metadata.gz: 0c58133319c6e482aca21bbfe8a5478c59b285b01ce5b1cbeed3136dd9e64138d94c5a3cfec3f590f6ddf35dd7bc1084271efc078faa7e3cb868b162af9f1d15
7
+ data.tar.gz: 7aba5316e9c073cd58359bac595c3ef9d2f9b02cecedadfa80131d9effc6b343a86c5d0df89db380bc2b17e2bd59a66ba5215e976fd8b0a3b3986e8fbcbe1caa
data/CHANGELOG.md CHANGED
@@ -4,6 +4,22 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.144.0 — 2026-11-11
8
+
9
+ ### Added
10
+
11
+ - **`Studio::SystemTestCase`, one shared system-test base.** The browser harness fixes that each app carried on its own now ship in the gem: the window resets to `desktop_size` before every test, `evaluate_async_script` gives back the script timeout Capybara 3.40.0 leaves at 2s, a phone viewport's DevTools emulation is cleared after every test, and Selenium gets a 180s read timeout. Helpers `phone_viewport!`, `assert_viewport_width` and `clear_emulation` come with it. An app adopts with `require "studio/system_test_case"` and `class ApplicationSystemTestCase < Studio::SystemTestCase`, keeps its own settings, setup, helpers and tests, and opts out of one hook by name with `skip_harness_hook :name, reason:`, which is logged. `lib/studio.rb` does not load it. See `docs/SYSTEM_TESTS.md`.
12
+ - **`Studio::TableComponent` takes `caption:` and `aria_label:`, and its scroll wrapper is keyboard-scrollable.** The wrapper is always `tabindex="0"` with an inset focus ring, so a keyboard user can focus it and scroll a wide table with the arrow keys. `caption:` renders a visually hidden `<caption>`; `aria_label:` labels the table without one. Either one also makes the wrapper a labelled `role="region"`. Pass one on every table.
13
+ - **`Studio::ButtonComponent` gains `tone: :outline`** (`btn-outline`: a primary border, unfilled until hover), drawn in light and dark on `/admin/style` and pinned by the screenshot baselines.
14
+
15
+ ### Changed
16
+
17
+ - **`Studio::ButtonComponent`'s default `md` size puts its label on the type scale.** It now adds the new `btn-md` utility: body size (1x `--type-base`, 16px; 14px under `type-dense`) at a 1.25 line height, so the button grows no taller. A bare `.btn` keeps `text-sm`, so hand-built buttons do not move. A host that relied on the 14px component label sees it at 16px.
18
+
19
+ ### Fixed
20
+
21
+ - **On-demand modals (`Studio.modals`) are hardened before Turf adopts them.** A fetch that does not answer within `Studio.modals.fetch_timeout_ms` (default 8000) is aborted and shows the error card, which now has Try again and a Close that works on a card opened `dismissible: false`. The fragment cache is dropped when the session changes by Turbo visit (sign-in, sign-out, a user switch), keyed on `Studio::Modals.session_marker`, so no cached fragment carries a dead CSRF token or the prior user's data. A fragment containing a `<script>` is refused, not inserted (the reason shows on the card in development and test). The loading card is `role="status"`. Prefetch also runs after each Turbo visit. No change for a consumer: `$store.modals.open(id, props, opts)` is unchanged; set `fetch_timeout_ms` in the initializer to change the wait.
22
+
7
23
  ## 0.143.2 — 2026-11-10
8
24
 
9
25
  ## 0.143.1 — 2026-11-10
data/Gemfile CHANGED
@@ -29,6 +29,13 @@ group :development, :test do
29
29
  # Rails.application directly rather than growing Rails-app scaffolding inside a
30
30
  # test fixture. Not a runtime dependency — consuming apps bring their own server.
31
31
  gem "puma", ">= 6.0"
32
+
33
+ # Capybara and selenium-webdriver drive Studio::SystemTestCase's own tests
34
+ # (test/lib/system_test_case_test.rb, test/system/). The gem does not declare
35
+ # them: lib/studio.rb never loads lib/studio/system_test_case.rb, and every
36
+ # host that inherits the base bundles both in its own test group.
37
+ gem "capybara", ">= 3.39"
38
+ gem "selenium-webdriver", ">= 4.20"
32
39
  end
33
40
 
34
41
  # solana-studio backs the style guide's THREE web3 specimens (Connect wallet and
data/README.md CHANGED
@@ -55,6 +55,7 @@ resolved.
55
55
  - **Connections**: generic OAuth 2.0 tooling to connect a third-party account. A host declares each provider with `Studio.define_connection` (endpoints, static or per-account; scopes; form or HTTP Basic client auth; a rotating, cached or client-credentials refresh policy). The engine gives it `Studio::Connection` (one table, tokens encrypted, `bin/rails g studio:connections`), the connect, callback and disconnect actions (`Studio::ConnectionFlow`) behind the host's admin guard, `Studio.draw_connection_routes`, an exact-key log filter, and a stand-in for the e2e lane. The tooling names no provider; the engine's own definitions are the Integrations below. See [`docs/CONNECTIONS.md`](docs/CONNECTIONS.md).
56
56
  - **Integrations**: ready connection definitions on that tooling for QuickBooks, Egnyte and Microsoft 365: the URLs, scopes, token behaviour and a minimal describe, never a reader. A host turns one on with `Studio.enable_integration :quickbooks, env: { client_id: "...", client_secret: "..." }` (env var names) and mounts the admin page with `Studio.draw_integrations_routes(self)`: a list with each status and Connect, and one page per integration with Disconnect and a host panel for the host's own live read. See [`docs/INTEGRATIONS.md`](docs/INTEGRATIONS.md).
57
57
  - **Surveys**: define a survey in app code (`config/surveys/*.rb`, `Studio.define_survey`) with six question types — emoji scale, 1–5 rating, single and multiple choice, short and long text — and the engine serves it at `/surveys/:slug`: one question per screen, autosave, resume, keyboard shortcuts, and a no-JavaScript fallback. `config.on_survey_completed` and `config.survey_ref_resolver` let the app fire its own goals and attribute email arrivals. An admin panel at `/admin/surveys` breaks results down per question and exports CSV. Opt in with `config.draw_survey_routes = true` and `config.draw_admin_survey_routes = true`. See [`docs/SURVEYS.md`](docs/SURVEYS.md).
58
+ - **System tests**: `Studio::SystemTestCase`, the one Capybara base every host's `ApplicationSystemTestCase` inherits (`require "studio/system_test_case"`). It resets the window before each test, restores the script timeout Capybara 3.40.0's `evaluate_async_script` leaves at 2s, clears phone emulation after each test, and gives Selenium a 180s read timeout. An app keeps its own settings (`phone_width`), setup, helpers and tests, and opts out of one hook by name with `skip_harness_hook`, which is logged. See [`docs/SYSTEM_TESTS.md`](docs/SYSTEM_TESTS.md).
58
59
 
59
60
  ## Tailwind
60
61
 
@@ -309,6 +310,7 @@ size and state live (Components section), and the gallery has a preview per stat
309
310
  <%= render Studio::ButtonComponent.new(label: "Save", type: "submit") %>
310
311
  <%= render Studio::ButtonComponent.new(label: "Delete", tone: :danger, size: :sm) %>
311
312
  <%= render Studio::ButtonComponent.new(label: "Docs", tone: :quiet, href: docs_path) %>
313
+ <%= render Studio::ButtonComponent.new(label: "Export", tone: :outline) %>
312
314
  <%= render Studio::ButtonComponent.new(label: "Save", type: "submit", loading: true, busy_label: "Saving…") %>
313
315
 
314
316
  <%= render(Studio::EmptyStateComponent.new(detail: "Invite someone to start.")) do |empty| %>
@@ -318,6 +320,7 @@ size and state live (Components section), and the gallery has a preview per stat
318
320
 
319
321
  <%= render Studio::TableComponent.new(
320
322
  rows: @users, sort: params[:sort], direction: params[:direction], empty: "No users yet.",
323
+ caption: "Team members",
321
324
  columns: [
322
325
  { key: :name, label: "User", sortable: true },
323
326
  { key: :created_at, label: "Joined", align: :right, cell: ->(u) { l(u.created_at.to_date) } }
@@ -326,11 +329,19 @@ size and state live (Components section), and the gallery has a preview per stat
326
329
 
327
330
  - **Button.** Tones map onto the shipped utilities: `primary` is `btn-primary`,
328
331
  `secondary` is `btn-secondary` (the success role, or the app's
329
- `--btn-secondary-*` retune), `quiet` is `btn-neutral`, `danger` is `btn-danger`.
330
- Sizes are `btn-sm`, bare `btn` and `btn-lg`. `loading:` uses the aria-busy
332
+ `--btn-secondary-*` retune), `quiet` is `btn-neutral`, `danger` is `btn-danger`,
333
+ `outline` is `btn-outline` (a primary border, unfilled until hover).
334
+ Sizes are `btn-sm`, `btn-md` and `btn-lg`. `btn-md` puts the medium label on
335
+ the type scale at body size (1x `--type-base`); a bare `btn` keeps `text-sm`,
336
+ so hand-built buttons do not move. `loading:` uses the aria-busy
331
337
  contract (docs/LOADER.md): the button is `aria-busy` and disabled and shows the
332
338
  pending spinner. A submit button always carries the hidden spinner, so a Turbo
333
339
  form that marks itself busy shows it with no script. A link cannot be loading.
340
+ **A busy form hides the icon on every button in it**, not only on the submit
341
+ button: the aria-busy contract hides every `[data-loader-idle]` under the busy
342
+ element, and the icon slot is idle content. A Cancel or Back button with an
343
+ icon inside the form loses its icon while the form submits. Put such a button
344
+ outside the form, or give it no icon.
334
345
  - **Empty state.** The `.empty-state` card, the message at `type-body`, the
335
346
  detail at `type-fine`, an optional icon, and at most one action, which is a
336
347
  `Studio::ButtonComponent`. `framed: false` drops the card. The
@@ -341,7 +352,10 @@ size and state live (Components section), and the gallery has a preview per stat
341
352
  `sort_url:` takes a callable for another URL shape. No rows renders one row with
342
353
  a bare empty state. On a phone the table scrolls sideways, as every engine table
343
354
  does; `min_width:` sets where. `sticky_header: true` adds
344
- `data-sticky-table-header`.
355
+ `data-sticky-table-header`. Give every table a name: `caption:` renders a
356
+ visually hidden `<caption>`, `aria_label:` labels the table without one. The
357
+ scroll wrapper is always `tabindex="0"`, so a keyboard user can scroll it, and a
358
+ name also makes it a labelled `role="region"`.
345
359
 
346
360
  ### The page header — `studio/_page_header`
347
361
 
@@ -737,11 +751,22 @@ change: `$store.modals.open("contest_rules", props)` works as before.
737
751
  |--------|---------|
738
752
  | `partial:` | The content partial, as the layout rendered it. The two rules below still apply (one root, no double quote in `x-data`). |
739
753
  | `auth:` | `:any` (default) or `:user`. A `:user` modal answers `401` to a signed-out request and is never prefetched for one. |
740
- | `prefetch:` | `true` fetches the modal when the browser idles after load (`requestIdleCallback`, else a timeout), so it opens with no wait. Use it for a modal opened on most visits. |
754
+ | `prefetch:` | `true` fetches the modal when the browser idles after load and after every Turbo visit (`requestIdleCallback`, else a timeout), so it opens with no wait. Use it for a modal opened on most visits. |
741
755
  | `locals:` | A Hash, a Symbol naming a helper the view answers (an existing `*_modal_locals` helper), or a callable taking the view. Resolved per request, in the view. |
742
756
 
743
757
  Ids are lower-case letters, digits, `-` and `_`.
744
758
 
759
+ **Locals run in the modal endpoint, not on the page.** The view that resolves
760
+ `locals:` belongs to `Studio::ModalsController`, so it has the helpers and the
761
+ session (`current_user`, `logged_in?`) but none of the page's instance
762
+ variables. A `*_modal_locals` helper that reads `@contest` or any other
763
+ page-scoped ivar gets `nil` there. Such a helper cannot move as it is: derive
764
+ what it needs from the session or the user, or keep that modal in the layout.
765
+
766
+ **The fetch timeout.** `Studio.modals.fetch_timeout_ms` (default `8000`) is how
767
+ long the host waits for a fragment. Set it in the initializer:
768
+ `Studio.modals.fetch_timeout_ms = 12_000`.
769
+
745
770
  **What happens.** The host advertises the registered ids on its template
746
771
  (`data-modal-host-remote-value`). `open(id)` for one that is not in the page
747
772
  yet fetches `GET /_studio/modals/<id>` (`Studio::ModalsController`), wraps the
@@ -749,12 +774,41 @@ fragment in the same `<template x-if>` a layout would render, appends it to the
749
774
  card, and opens. A fetch that answers within 150 ms opens with no loading state.
750
775
  A slower one opens the card with the engine's processing card, held at least
751
776
  `StudioModals.MIN_LOAD_MS`, then the content replaces it. A failed fetch (a
752
- network error, a `500`, a `401`, or a host filter's redirect) shows the
753
- engine's error card, whose button closes; the next open tries again.
754
-
755
- The fragment is held for the page's lifetime, across Turbo visits: a later open
756
- inserts it from memory into the new page's host, once, with no second request.
757
- A fetch that lands after a Turbo visit opens nothing on the new page.
777
+ network error, a `500`, a `401`, a host filter's redirect, or no answer within
778
+ `fetch_timeout_ms`) shows the engine's error card. **Try again** re-runs the
779
+ fetch with the card in place; **Close** closes it, even on a card opened
780
+ `dismissible: false` (Escape and the backdrop still respect that flag). The next
781
+ open tries again too. The loading card is `role="status"` (`aria-live`
782
+ polite); the error card is `role="alert"`.
783
+
784
+ **A fragment with a `<script>` is refused**, never inserted: it shows the error
785
+ card. In development and test the card names the reason; in production it shows
786
+ the generic line. Nothing is logged to the console either way; the endpoint
787
+ records an `ErrorLog` row instead, once per modal id per process. Put behaviour in a module or a Stimulus
788
+ controller.
789
+
790
+ The fragment is held across Turbo visits: a later open inserts it from memory
791
+ into the new page's host, once, with no second request. A fetch that lands
792
+ after a Turbo visit opens nothing on the new page.
793
+
794
+ **The cache follows the session.** A fragment can carry the viewer's data and a
795
+ form's CSRF token. The host advertises a session marker
796
+ (`Studio::Modals.session_marker`: a digest of the session id and the user id
797
+ under `Studio.session_key`). The browser compares it on every open and every
798
+ `turbo:load`. When it changes (sign-out, a sign-in, a user switch, by Turbo
799
+ visit, or a `session:changed` from studio/session that is a real identity
800
+ change: `signed_out`, `changed`, or another viewer's identities, never the first
801
+ seating at load or a `stale` → `authenticated` revalidation), the browser drops
802
+ every cached fragment, every fetch in flight and every template already
803
+ inserted, so the next open fetches fresh. A modal open at that moment keeps its
804
+ template until it closes: a card is never emptied in place. The session id is
805
+ what `reset_session` rotates, and the CSRF secret rotates with it; the user id
806
+ catches a sign-in that keeps the session. The `csrf-token` meta is not used: it
807
+ is masked anew on every render, so it would empty the cache on every visit.
808
+
809
+ **Prefetch** runs when the browser idles after the first load and again after
810
+ every Turbo visit (`turbo:load`). An id already cached or in flight is not
811
+ fetched twice.
758
812
 
759
813
  **What does not change.** An id the registry does not name takes the old
760
814
  synchronous `open()` path, and an app that registers nothing renders the host
@@ -766,8 +820,8 @@ One difference for a registered id: during the first open's fetch,
766
820
  with no layout. It skips the host's `require_authentication` so an `:any`
767
821
  modal serves a signed-out visitor, and answers a `:user` modal's signed-out
768
822
  request with a bare `401`, never the `/login` redirect. Each answer is
769
- `private` with `Vary: Cookie` and an ETag. A fragment carries no script, so
770
- the page's CSP is unchanged. Only the shared host (`$store.modals`) loads on
823
+ `private` with `Vary: Cookie` and an ETag. A fragment carries no script (the
824
+ browser refuses one that does), so the page's CSP is unchanged. Only the shared host (`$store.modals`) loads on
771
825
  demand; a scoped host renders its modals as before.
772
826
 
773
827
  **Do not do both.** An id that the registry names and the layout still
@@ -1709,6 +1709,15 @@ body.modal-open .studio-app-banner {
1709
1709
  @apply px-8 py-3 text-base rounded-xl shadow-sm;
1710
1710
  }
1711
1711
 
1712
+ /* The medium button's label on the type scale: body size (1x --type-base), where a
1713
+ bare .btn keeps text-sm (14px, off the scale) so no host's hand-built button moves.
1714
+ Studio::ButtonComponent size: :md adds it. The 1.25 line height holds the bare
1715
+ .btn's 20px line, so the button grows no taller. Follows type-dense like type-body. */
1716
+ @utility btn-md {
1717
+ font-size: calc(var(--type-base, 16px) * 1);
1718
+ line-height: 1.25;
1719
+ }
1720
+
1712
1721
  /* A busy button (Studio::ButtonComponent loading:, or a submit Turbo marks busy) is
1713
1722
  disabled so it takes no second click, but it is working, not unavailable: it keeps
1714
1723
  its full colour and shows the progress cursor. The busy words sit in the button's
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Studio
4
- # Every state Studio::ButtonComponent can show: the four tones, the three
4
+ # Every state Studio::ButtonComponent can show: the five tones, the three
5
5
  # sizes, a link, an icon, disabled and loading. No @param tags: the gallery
6
6
  # takes no input.
7
7
  class ButtonComponentPreview < ViewComponent::Preview
8
- # The four tones, at the default size.
8
+ # The five tones, at the default size.
9
9
  def tones
10
10
  render_with_template(locals: { tones: Studio::ButtonComponent::TONES.keys })
11
11
  end
@@ -10,5 +10,5 @@
10
10
  %>
11
11
  <div class="card overflow-hidden" data-specimen="table">
12
12
  <%= render Studio::TableComponent.new(rows: rows, columns: columns, sort: sort, direction: direction,
13
- sort_url: ->(key, dir) { "?sort=#{key}&direction=#{dir}" }, empty: "No members yet.", min_width: 520) %>
13
+ sort_url: ->(key, dir) { "?sort=#{key}&direction=#{dir}" }, empty: "No members yet.", min_width: 520, caption: "Team members") %>
14
14
  </div>
@@ -6,6 +6,7 @@ module Studio
6
6
  # render Studio::ButtonComponent.new(label: "Save")
7
7
  # render Studio::ButtonComponent.new(label: "Delete", tone: :danger, size: :sm)
8
8
  # render Studio::ButtonComponent.new(label: "Docs", tone: :quiet, href: "/docs")
9
+ # render Studio::ButtonComponent.new(label: "Export", tone: :outline)
9
10
  # render Studio::ButtonComponent.new(label: "Save", type: "submit", loading: true, busy_label: "Saving…")
10
11
  # render(Studio::ButtonComponent.new(label: "New")) { |b| b.with_icon { plus_svg } }
11
12
  #
@@ -18,7 +19,12 @@ module Studio
18
19
  # secondary btn-secondary the success role, or the app's retune
19
20
  # quiet btn-neutral a second action: Cancel, Back, Next
20
21
  # danger btn-danger a destructive action
21
- # size sm btn-sm · md btn · lg btn-lg
22
+ # outline btn-outline a bordered, unfilled action on the
23
+ # primary colour; it fills on hover
24
+ # size sm btn-sm · md btn-md · lg btn-lg
25
+ #
26
+ # md's label sits on the type scale at body size (btn-md, 1x --type-base); a
27
+ # bare .btn keeps text-sm, so a host's hand-built buttons do not move.
22
28
  #
23
29
  # Its public surface, which a release changes only with a Breaking line in the
24
30
  # changelog (docs/FRONT_END_STANDARD.md, "A primitive's public surface"):
@@ -36,7 +42,9 @@ module Studio
36
42
  # always carries the pending loader, hidden, so a Turbo form that marks itself
37
43
  # aria-busy shows it with no script here. The icon is the idle content and
38
44
  # gives way to the loader; busy_label: words replace the label while busy.
39
- # A link cannot be busy, so loading: with href: is refused.
45
+ # A link cannot be busy, so loading: with href: is refused. A busy FORM hides
46
+ # the icon on every button inside it, not just the submit (engine-motion.css,
47
+ # :where(form, button)[aria-busy] [data-loader-idle]); README, "UI Primitives".
40
48
  #
41
49
  # Every class string is written out whole: Tailwind finds utilities by scanning
42
50
  # source text, and engine.css adds app/components to every host's scan.
@@ -53,12 +61,13 @@ module Studio
53
61
  primary: "btn-primary",
54
62
  secondary: "btn-secondary",
55
63
  quiet: "btn-neutral",
56
- danger: "btn-danger"
64
+ danger: "btn-danger",
65
+ outline: "btn-outline"
57
66
  }.freeze
58
67
 
59
68
  SIZES = {
60
69
  sm: "btn-sm",
61
- md: nil,
70
+ md: "btn-md",
62
71
  lg: "btn-lg"
63
72
  }.freeze
64
73
 
@@ -1,5 +1,6 @@
1
- <div class="<%= Studio::TableComponent::WRAPPER %>" data-table>
1
+ <%= tag.div(**wrapper_attributes) do %>
2
2
  <%= tag.table(**table_attributes) do %>
3
+ <% if caption %><caption class="<%= Studio::TableComponent::CAPTION %>"><%= caption %></caption><% end %>
3
4
  <thead class="<%= Studio::TableComponent::HEAD %>">
4
5
  <tr>
5
6
  <% columns.each do |column| %>
@@ -29,4 +30,4 @@
29
30
  <% end %>
30
31
  </tbody>
31
32
  <% end %>
32
- </div>
33
+ <% end %>
@@ -12,7 +12,8 @@ module Studio
12
12
  # { key: :created_at, label: "Joined", align: :right, cell: ->(user) { l(user.created_at.to_date) } }
13
13
  # ],
14
14
  # sort: params[:sort], direction: params[:direction],
15
- # empty: "No users yet."
15
+ # empty: "No users yet.",
16
+ # caption: "Team members"
16
17
  # )
17
18
  #
18
19
  # A column is a Hash: key: (required), label: (required), sortable: (false),
@@ -32,21 +33,36 @@ module Studio
32
33
  # (admin_model_table, the emails index): the table keeps its columns and its
33
34
  # wrapper scrolls. min_width: sets the width under which it scrolls.
34
35
  #
36
+ # A NAME AND A KEYBOARD SCROLL. The scroll wrapper takes tabindex="0", so a
37
+ # keyboard user can focus it and scroll it with the arrow keys (WCAG 2.1.1; axe's
38
+ # scrollable-region-focusable), and it shows a focus ring inside its edge, so a
39
+ # card's overflow-hidden cannot clip it.
40
+ # caption: renders a <caption> (visually hidden: the page's own heading is the
41
+ # visible title) and aria_label: labels the table without one. Either one also
42
+ # makes the wrapper a named role="region", so a screen reader announces what it
43
+ # entered. With neither, the wrapper stays focusable but takes no role: a
44
+ # region with no name is noise. Pass one.
45
+ #
35
46
  # Its public surface, which a release changes only with a Breaking line in the
36
47
  # changelog (docs/FRONT_END_STANDARD.md, "A primitive's public surface"):
37
48
  # inputs rows:, columns:, id:, sort:, direction:, sort_url:, sort_param:,
38
- # direction_param:, empty:, min_width:, sticky_header:
49
+ # direction_param:, empty:, min_width:, sticky_header:,
50
+ # caption:, aria_label:
39
51
  # data-* data-table on the wrapper; data-table-sort-key on a sortable
40
52
  # header; data-table-empty on the empty row; data-sticky-table-header
41
53
  # on the table when sticky_header: (studio/sticky_table_header)
42
- # aria-* aria-sort on the sorted header
54
+ # aria-* aria-sort on the sorted header; aria-label on the table
55
+ # (aria_label:) and on the wrapper, role="region" (caption: or
56
+ # aria_label:)
57
+ # tabindex tabindex="0" on the wrapper
43
58
  #
44
59
  # Every class string is written out whole: Tailwind finds utilities by scanning
45
60
  # source text, and engine.css adds app/components to every host's scan.
46
61
  class TableComponent < ViewComponent::Base
47
62
  strip_trailing_whitespace
48
63
 
49
- WRAPPER = "overflow-x-auto"
64
+ WRAPPER = "overflow-x-auto focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
65
+ CAPTION = "sr-only"
50
66
  TABLE = "w-full type-body"
51
67
  HEAD = "bg-surface-alt text-muted type-fine uppercase tracking-wide"
52
68
  BODY = "divide-y divide-subtle"
@@ -71,7 +87,8 @@ module Studio
71
87
  attr_reader :rows, :columns, :empty
72
88
 
73
89
  def initialize(rows:, columns:, id: nil, sort: nil, direction: nil, sort_url: nil, sort_param: "sort",
74
- direction_param: "direction", empty: "Nothing here yet.", min_width: nil, sticky_header: false)
90
+ direction_param: "direction", empty: "Nothing here yet.", min_width: nil, sticky_header: false,
91
+ caption: nil, aria_label: nil)
75
92
  super()
76
93
  raise ArgumentError, "Studio::TableComponent needs at least one column" if columns.blank?
77
94
 
@@ -86,12 +103,29 @@ module Studio
86
103
  @empty = empty
87
104
  @min_width = min_width
88
105
  @sticky_header = sticky_header ? true : false
106
+ @caption = caption.presence
107
+ @aria_label = aria_label.presence
108
+ end
109
+
110
+ attr_reader :caption
111
+
112
+ # The name the wrapper's region announces: the caption, else aria_label:.
113
+ def accessible_name = @caption || @aria_label
114
+
115
+ def wrapper_attributes
116
+ attrs = { class: WRAPPER, tabindex: "0", data: { table: "" } }
117
+ if accessible_name
118
+ attrs[:role] = "region"
119
+ attrs[:aria] = { label: accessible_name }
120
+ end
121
+ attrs
89
122
  end
90
123
 
91
124
  def table_attributes
92
125
  attrs = { class: TABLE, id: @id }
93
126
  attrs[:style] = "min-width: #{@min_width.to_i}px" if @min_width
94
127
  attrs[:data] = { sticky_table_header: "" } if @sticky_header
128
+ attrs[:aria] = { label: @aria_label } if @aria_label && !@caption
95
129
  attrs.compact
96
130
  end
97
131
 
@@ -22,7 +22,14 @@ module Studio
22
22
  # so it is private: Rails' default for a dynamic GET (max-age=0, private,
23
23
  # must-revalidate, with an ETag) is kept, and Vary: Cookie is added so no
24
24
  # cache answers one session with another's fragment. The browser holds the
25
- # fragment for the page's lifetime anyway (studio/modal_host).
25
+ # fragment across Turbo visits and drops it when the host's session marker
26
+ # changes (Studio::Modals.session_marker, studio/modal_host).
27
+ #
28
+ # NO SCRIPT. studio/modal_host refuses a fragment that carries a <script>.
29
+ # The browser says nothing in production, so the refusal is recorded here,
30
+ # as an ErrorLog row, where it is seen. The fragment is still sent: the
31
+ # browser's refusal (with its reason on the card in development) is the
32
+ # behaviour; this is the record.
26
33
  class ModalsController < ::ApplicationController
27
34
  skip_before_action :require_authentication, raise: false
28
35
 
@@ -37,10 +44,34 @@ module Studio
37
44
  # the host's error page for the browser to mistake for a fragment.
38
45
  response.headers["X-Studio-Modal"] = entry.id
39
46
  response.headers["Vary"] = [response.headers["Vary"], "Cookie"].compact.join(", ")
47
+ record_script_fragment(entry) if response.body.to_s.match?(SCRIPT_TAG)
40
48
  end
41
49
 
42
50
  private
43
51
 
52
+ SCRIPT_TAG = %r{<script[\s>/]}i
53
+
54
+ # Ids already recorded in this process: one row per bad modal, not one per
55
+ # request (a prefetched bad fragment would otherwise write a row on every
56
+ # page view).
57
+ RECORDED_SCRIPT_IDS = Set.new
58
+ RECORDED_SCRIPT_LOCK = Mutex.new
59
+
60
+ def self.forget_recorded_scripts! = RECORDED_SCRIPT_LOCK.synchronize { RECORDED_SCRIPT_IDS.clear }
61
+
62
+ # A best-effort record: logging never breaks the answer.
63
+ def record_script_fragment(entry)
64
+ return unless defined?(::ErrorLog) && ::ErrorLog.respond_to?(:capture!)
65
+ return unless RECORDED_SCRIPT_LOCK.synchronize { RECORDED_SCRIPT_IDS.add?(entry.id) }
66
+
67
+ error = RuntimeError.new("Studio.modals #{entry.id}: #{entry.partial} renders a <script>; " \
68
+ "studio/modal_host refuses it. Move the behaviour to a module or a Stimulus controller.")
69
+ error.set_backtrace(caller)
70
+ ::ErrorLog.capture!(error)
71
+ rescue StandardError
72
+ nil
73
+ end
74
+
44
75
  def studio_modal_viewer_signed_in?
45
76
  respond_to?(:logged_in?, true) && logged_in?
46
77
  end
@@ -306,15 +306,50 @@ function focusTrap(env, held) {
306
306
  // 'error' (the host's error card) and forgets the fetch, so the next open
307
307
  // retries. So for the first LOAD_DELAY_MS of a first open, isOpen(id) is false.
308
308
  //
309
- // THE CACHE lives for the page's lifetime: fragments by id, beside the store,
310
- // not on it. A Turbo visit brings a fresh host without the inserted templates
309
+ // THE TIMEOUT. A fetch that has not answered in cfg.timeout_ms (the server's
310
+ // Studio.modals.fetch_timeout_ms, else DEFAULT_FETCH_TIMEOUT_MS) is aborted and
311
+ // shows the error card. Its Try again (retryRemote) re-runs the fetch with the
312
+ // card in place; its Close calls close() directly, so a card opened
313
+ // dismissible: false still has a way out.
314
+ //
315
+ // THE CACHE: fragments by id, beside the store, not on it, keyed on the
316
+ // session. A Turbo visit brings a fresh host without the inserted templates
311
317
  // (or a snapshot clone with them), so insertion checks the host every time and
312
318
  // inserts only what is missing; that check is what keeps a Turbo visit from
313
319
  // inserting a modal twice. A fetch that resolves after a Turbo visit replaced
314
320
  // the body (doc.body changed) opens nothing on the new page.
321
+ //
322
+ // THE SESSION KEY. A fragment can carry the viewer's data and a form's CSRF
323
+ // token, and a Turbo visit across sign-in, sign-out or a user switch keeps the
324
+ // document, and with it this cache. So the host advertises cfg.session, a
325
+ // digest of the session id and the signed-in user id (Studio::Modals.
326
+ // session_marker), and every open and every turbo:load compares it: a change
327
+ // drops every cached fragment, every fetch in flight, and every template
328
+ // already inserted on this page, except the template of a modal open now,
329
+ // which goes when it closes (a card is never emptied in place).
330
+ // studio/session's session:changed drops them too, but only on a real
331
+ // identity change (sessionIdentityChange), never on the first seating or a
332
+ // stale -> authenticated revalidation. Why that signal: the session id is what
333
+ // reset_session rotates, and the CSRF secret rotates with it, so a cached form
334
+ // token goes stale exactly when it changes; the user id catches a sign-in
335
+ // that keeps the session. The page's csrf-token meta would not do: it is
336
+ // masked afresh on every render, so it would empty the cache on every visit.
337
+ //
338
+ // THE SCRIPT RULE. A fragment containing a <script> is refused, never
339
+ // inserted: an inserted script would run (or not) by accident of how Alpine
340
+ // clones the template, and it carries no CSP nonce. It shows the error card;
341
+ // with cfg.debug (development and test) the card names the reason. There is
342
+ // no console output in either case: the endpoint records it server-side in an
343
+ // ErrorLog row (Studio::ModalsController).
344
+ //
345
+ // PREFETCH runs when the browser idles after the first load AND after every
346
+ // Turbo visit (turbo:load), so a page reached by Turbo prefetches what it
347
+ // flags, for the viewer it was rendered for.
315
348
 
316
349
  export const REMOTE_MODAL_ATTR = 'data-studio-remote-modal'
317
350
  export const LOAD_DELAY_MS = 150
351
+ export const DEFAULT_FETCH_TIMEOUT_MS = 8000
352
+ export const REMOTE_ERROR_TEXT = 'Check your connection and try again.'
318
353
 
319
354
  // The host template advertising on-demand modals, if this page has one.
320
355
  export function remoteHost(doc) {
@@ -329,6 +364,8 @@ export function remoteConfig(doc) {
329
364
  var cfg = JSON.parse(el.getAttribute('data-modal-host-remote-value'))
330
365
  if (!cfg || typeof cfg.url !== 'string' || !Array.isArray(cfg.ids)) return null
331
366
  if (!Array.isArray(cfg.prefetch)) cfg.prefetch = []
367
+ if (!(cfg.timeout_ms > 0)) cfg.timeout_ms = DEFAULT_FETCH_TIMEOUT_MS
368
+ if (typeof cfg.session !== 'string') cfg.session = ''
332
369
  return cfg
333
370
  } catch (e) {
334
371
  return null
@@ -381,18 +418,61 @@ export function remoteModalUrl(cfg, id) {
381
418
  return cfg.url.replace('__id__', encodeURIComponent(id))
382
419
  }
383
420
 
421
+ // A refused fragment's error. Its message is shown only with cfg.debug.
422
+ function remoteError(message) {
423
+ var err = new Error(message)
424
+ err.studioModal = true
425
+ return err
426
+ }
427
+
428
+ // True when the markup carries a <script> element. A text match, not a parse:
429
+ // the fragment is server-rendered, so this guards an app's own mistake, and a
430
+ // false positive (the word in an attribute) errs on the safe side.
431
+ export function fragmentHasScript(markup) {
432
+ return /<script[\s>\/]/i.test(String(markup))
433
+ }
434
+
384
435
  // One fetch. Rejects unless the answer is the fragment the endpoint sends
385
436
  // (X-Studio-Modal names the id): a host filter's redirect to a full page, or a
386
- // 401, is an error, never a modal.
437
+ // 401, is an error, never a modal. Rejects too when nothing answers inside
438
+ // cfg.timeout_ms (the request is aborted where the browser can), and when the
439
+ // fragment carries a <script>.
387
440
  export function fetchRemoteModal(win, cfg, id) {
388
- return win.fetch(remoteModalUrl(cfg, id), {
441
+ var ms = cfg.timeout_ms > 0 ? cfg.timeout_ms : DEFAULT_FETCH_TIMEOUT_MS
442
+ var controller = typeof win.AbortController === 'function' ? new win.AbortController() : null
443
+ var init = {
389
444
  credentials: 'same-origin',
390
445
  headers: { 'Accept': 'text/html', 'X-Requested-With': 'XMLHttpRequest' }
391
- }).then(function (res) {
392
- if (!res.ok || !res.headers || res.headers.get('X-Studio-Modal') !== id) {
393
- throw new Error('studio/modal_host: ' + id + ' answered ' + res.status)
446
+ }
447
+ if (controller) init.signal = controller.signal
448
+
449
+ return new Promise(function (resolve, reject) {
450
+ var settled = false
451
+ var timer = win.setTimeout(function () {
452
+ if (settled) return
453
+ settled = true
454
+ if (controller) { try { controller.abort() } catch (e) {} }
455
+ reject(remoteError('studio/modal_host: ' + id + ' did not answer in ' + ms + 'ms'))
456
+ }, ms)
457
+ function finish(fn, value) {
458
+ if (settled) return
459
+ settled = true
460
+ if (win.clearTimeout) win.clearTimeout(timer)
461
+ fn(value)
394
462
  }
395
- return res.text()
463
+
464
+ win.fetch(remoteModalUrl(cfg, id), init).then(function (res) {
465
+ if (!res.ok || !res.headers || res.headers.get('X-Studio-Modal') !== id) {
466
+ throw remoteError('studio/modal_host: ' + id + ' answered ' + res.status)
467
+ }
468
+ return res.text()
469
+ }).then(function (markup) {
470
+ if (fragmentHasScript(markup)) {
471
+ throw remoteError('studio/modal_host: ' + id + ' was refused: its fragment carries a <script>. ' +
472
+ 'Move the behaviour to a module or a Stimulus controller.')
473
+ }
474
+ finish(resolve, markup)
475
+ }).catch(function (err) { finish(reject, err) })
396
476
  })
397
477
  }
398
478
 
@@ -402,21 +482,142 @@ export function whenIdle(win, fn) {
402
482
  else win.setTimeout(fn, 1500)
403
483
  }
404
484
 
405
- // The loader one shared store keeps in its closure.
406
- export function createRemoteModals(env) {
485
+ // Removes the inserted registrations from this page's cards (the session
486
+ // changed, so what they carry is stale), except those whose id is in keep:
487
+ // the modals on the stack now, which must never be emptied in place. Answers
488
+ // the ids it kept.
489
+ function removeRemoteModals(doc, keep) {
490
+ keep = keep || []
491
+ var kept = []
492
+ var cards = remoteCards(doc)
493
+ for (var i = 0; i < cards.length; i++) {
494
+ var kids = Array.prototype.slice.call(cards[i].children || [])
495
+ for (var k = 0; k < kids.length; k++) {
496
+ var kidId = kids[k].getAttribute ? kids[k].getAttribute(REMOTE_MODAL_ATTR) : null
497
+ if (kidId !== null && keep.indexOf(kidId) !== -1) {
498
+ if (kept.indexOf(kidId) === -1) kept.push(kidId)
499
+ continue
500
+ }
501
+ if (kidId !== null) {
502
+ if (typeof cards[i].removeChild === 'function') cards[i].removeChild(kids[k])
503
+ else if (typeof kids[k].remove === 'function') kids[k].remove()
504
+ }
505
+ }
506
+ }
507
+ return kept
508
+ }
509
+
510
+ // THE SETTLED STATES of studio/session (docs/SESSION_DRIFT.md). A settled
511
+ // event whose fingerprint (user + bound identities) differs from the last is an
512
+ // identity change; rehydrated is one ("possibly as someone new"); stale is not.
513
+ var SETTLED_SESSION_STATES = ['anonymous', 'authenticated', 'signed_out', 'changed', 'rehydrated']
514
+
515
+ // Is this session:changed detail a REAL change of who is viewing? studio/session
516
+ // sends the event on every transition, including the first seating on page
517
+ // load (previous null) and stale -> authenticated when a hidden tab returns,
518
+ // and neither may throw the cache away. lastIdentity is the identity string
519
+ // the previous settled event carried (null before the first). Answers
520
+ // { real, identity }: identity is the string to remember.
521
+ export function sessionIdentityChange(detail, lastIdentity) {
522
+ var d = detail || {}
523
+ var snap = d.snapshot || {}
524
+ var identity = snap.state === 'anonymous' || snap.state === 'signed_out' || d.state === 'anonymous' || d.state === 'signed_out'
525
+ ? 'anonymous'
526
+ : (snap.fingerprint || JSON.stringify(snap.identities || {}))
527
+ // First seating, or the store has no state yet: remember, never drop.
528
+ if (d.previous === null || d.previous === undefined || !d.state) return { real: false, identity: identity }
529
+ // Someone else observed (changed) or the server reports nobody (signed_out).
530
+ if (d.state === 'changed' || (d.state === 'signed_out' && d.previous !== 'signed_out')) {
531
+ return { real: true, identity: identity }
532
+ }
533
+ // A settled state with a different identity than the last one seen.
534
+ if (SETTLED_SESSION_STATES.indexOf(d.state) !== -1 && lastIdentity !== null && identity !== lastIdentity) {
535
+ return { real: true, identity: identity }
536
+ }
537
+ // stale, or a return to the same identity: revalidation.
538
+ return { real: false, identity: SETTLED_SESSION_STATES.indexOf(d.state) !== -1 ? identity : lastIdentity }
539
+ }
540
+
541
+ // The loader one shared store keeps in its closure. openIds() answers the ids
542
+ // on the store's stack now: their templates survive a drop until they close.
543
+ export function createRemoteModals(env, openIds) {
407
544
  var doc = env.doc
408
545
  var win = env.win
546
+ var openNowIds = typeof openIds === 'function' ? openIds : function () { return [] }
547
+ var staleOpen = [] // ids whose stale template was kept because they were open
409
548
  var pending = {} // id -> Promise<markup>, while in flight or resolved
410
549
  var ready = {} // id -> markup, once resolved
550
+ var session = null // the cfg.session the cache was filled under
551
+ var generation = 0 // bumped by a drop, so a fetch from before it caches nothing
552
+
553
+ // Drops the cache at once. The templates go too, except those of modals on
554
+ // the stack now: a card is never emptied in place (a dismissible: false one
555
+ // would leave the viewer with no way out). Those go when they close (flush).
556
+ function drop() {
557
+ pending = {}
558
+ ready = {}
559
+ generation++
560
+ staleOpen = removeRemoteModals(doc, openNowIds())
561
+ }
562
+
563
+ // Removes each kept stale template whose modal has left the stack. Called
564
+ // whenever the stack changes.
565
+ function flush() {
566
+ if (staleOpen.length === 0) return
567
+ var open = openNowIds()
568
+ var keep = staleOpen.filter(function (id) { return open.indexOf(id) !== -1 })
569
+ if (keep.length === staleOpen.length) return
570
+ // Only the kept ids are stale; every other inserted template is fresh.
571
+ var gone = staleOpen.filter(function (id) { return keep.indexOf(id) === -1 })
572
+ var cards = remoteCards(doc)
573
+ for (var i = 0; i < cards.length; i++) {
574
+ var kids = Array.prototype.slice.call(cards[i].children || [])
575
+ for (var k = 0; k < kids.length; k++) {
576
+ var kidId = kids[k].getAttribute ? kids[k].getAttribute(REMOTE_MODAL_ATTR) : null
577
+ if (kidId !== null && gone.indexOf(kidId) !== -1) {
578
+ if (typeof cards[i].removeChild === 'function') cards[i].removeChild(kids[k])
579
+ else if (typeof kids[k].remove === 'function') kids[k].remove()
580
+ }
581
+ }
582
+ }
583
+ staleOpen = keep
584
+ }
585
+
586
+ // Drops the cache when the advertised session differs from the one it was
587
+ // filled under. The first config seen only sets the key.
588
+ function sync(cfg) {
589
+ if (!cfg) return
590
+ if (session === null) { session = cfg.session; return }
591
+ if (cfg.session === session) return
592
+ session = cfg.session
593
+ drop()
594
+ }
595
+
596
+ // studio/session's session:changed drops the cache too, for a change that
597
+ // happens without a page render to carry a new marker, but ONLY on a real
598
+ // identity change (sessionIdentityChange): the event also fires on the
599
+ // first seating at every load and on stale -> authenticated revalidation,
600
+ // which must keep a finished prefetch and an open card. The host's marker
601
+ // (sync, on every open and turbo:load) stays the primary signal.
602
+ var lastIdentity = null
603
+ if (doc && typeof doc.addEventListener === 'function') {
604
+ doc.addEventListener('session:changed', function (event) {
605
+ var verdict = sessionIdentityChange(event && event.detail, lastIdentity)
606
+ lastIdentity = verdict.identity
607
+ if (verdict.real) drop()
608
+ })
609
+ }
411
610
 
412
611
  function load(cfg, id) {
612
+ sync(cfg)
413
613
  if (ready[id] !== undefined) return Promise.resolve(ready[id])
414
614
  if (!pending[id]) {
415
- pending[id] = fetchRemoteModal(win, cfg, id).then(function (markup) {
416
- ready[id] = markup
615
+ var gen = generation
616
+ var mine = pending[id] = fetchRemoteModal(win, cfg, id).then(function (markup) {
617
+ if (gen === generation) ready[id] = markup
417
618
  return markup
418
619
  }, function (err) {
419
- delete pending[id]
620
+ if (pending[id] === mine) delete pending[id]
420
621
  throw err
421
622
  })
422
623
  }
@@ -424,10 +625,11 @@ export function createRemoteModals(env) {
424
625
  }
425
626
 
426
627
  return {
427
- // The config when id is advertised here, else null.
628
+ // The config when id is advertised here, else null. Checks the session.
428
629
  handles: function (id) {
429
630
  if (typeof id !== 'string') return null
430
631
  var cfg = remoteConfig(doc)
632
+ sync(cfg)
431
633
  return (cfg && cfg.ids.indexOf(id) !== -1) ? cfg : null
432
634
  },
433
635
  load: load,
@@ -437,8 +639,10 @@ export function createRemoteModals(env) {
437
639
  return ready[id] !== undefined && insertRemoteModal(doc, id, ready[id])
438
640
  },
439
641
  insert: function (id, markup) { return insertRemoteModal(doc, id, markup) },
642
+ flush: flush,
440
643
  prefetch: function () {
441
644
  var cfg = remoteConfig(doc)
645
+ sync(cfg)
442
646
  if (!cfg || typeof win.fetch !== 'function') return
443
647
  cfg.prefetch.forEach(function (id) {
444
648
  // A failed prefetch is retried by the open that needs it.
@@ -460,7 +664,19 @@ export function createModalStore(env) {
460
664
  // id -> 'loading' | 'error' for an on-demand modal; read by the host's
461
665
  // loading and error cards and by each fetched registration's x-if.
462
666
  store._remote = {}
463
- var remote = createRemoteModals(env)
667
+ // id -> the reason the last load failed, shown on the error card only when
668
+ // the host advertises debug (development and test).
669
+ store._remoteReason = {}
670
+ var remote = createRemoteModals(env, function () {
671
+ return store.stack.map(function (entry) { return entry.id })
672
+ })
673
+ // Every stack change (open, close, swap, sweep) goes through _sync, so a
674
+ // stale template kept for an open card is removed once that card leaves.
675
+ var baseSync = store._sync
676
+ store._sync = function () {
677
+ baseSync.call(this)
678
+ remote.flush()
679
+ }
464
680
 
465
681
  // open(id, props, opts)
466
682
  // opts.replace: swap the top entry. With a visible card, a directional
@@ -470,52 +686,95 @@ export function createModalStore(env) {
470
686
  // An on-demand id not yet in the host loads first (openRemote below).
471
687
  store.open = function (id, props, opts) {
472
688
  var cfg = remote.handles(id)
473
- if (cfg && !remote.insertIfReady(id)) return openRemote(this, cfg, id, props, opts)
689
+ if (cfg && !remote.insertIfReady(id)) return openRemote(this, cfg, id, props, opts, false)
474
690
  return openNow.call(this, id, props, opts)
475
691
  }
476
692
 
477
- // The first open of an on-demand id. Never throws and never leaves a promise
478
- // unhandled: every outcome is the modal, the loading card, or the error card.
479
- function openRemote(self, cfg, id, props, opts) {
693
+ // The error card's Try again: re-runs the current entry's fetch with the
694
+ // entry left where it is, showing the loading card meanwhile.
695
+ store.retryRemote = function () {
696
+ var entry = this.current()
697
+ if (!entry || this._remote[entry.id] !== 'error') return
698
+ var cfg = remote.handles(entry.id)
699
+ if (!cfg) return
700
+ // Try again unmounts the focused button, so focus goes back to the
701
+ // backdrop each time the card's content changes (here and in openRemote).
702
+ if (remote.insertIfReady(entry.id)) { delete this._remote[entry.id]; this.refocus(); return }
703
+ openRemote(this, cfg, entry.id, entry.props, null, true)
704
+ this.refocus()
705
+ }
706
+
707
+ // The error card's message: the generic line, or with cfg.debug the reason.
708
+ store.remoteErrorText = function () {
709
+ var entry = this.current()
710
+ var reason = entry && this._remoteReason[entry.id]
711
+ return reason || REMOTE_ERROR_TEXT
712
+ }
713
+
714
+ // The first open of an on-demand id (or a retry, when inPlace: the entry is
715
+ // already on the stack). Never throws and never leaves a promise unhandled:
716
+ // every outcome is the modal, the loading card, or the error card.
717
+ function openRemote(self, cfg, id, props, opts, inPlace) {
480
718
  var body = env.doc.body
481
- var shown = false
719
+ var shown = !!inPlace
482
720
  var hold = null
483
721
  var done = false
722
+ var timer = null
484
723
  if (self._remote[id] === 'error') delete self._remote[id]
724
+ delete self._remoteReason[id]
725
+ var minLoad = function () { return (win.StudioModals && win.StudioModals.MIN_LOAD_MS) || MIN_LOAD_MS }
485
726
 
486
- var timer = win.setTimeout(function () {
487
- if (done || env.doc.body !== body) return
488
- shown = true
489
- hold = holdAtLeast((win.StudioModals && win.StudioModals.MIN_LOAD_MS) || MIN_LOAD_MS)
727
+ if (inPlace) {
728
+ hold = holdAtLeast(minLoad())
490
729
  self._remote[id] = 'loading'
491
- openNow.call(self, id, props, opts)
492
- }, LOAD_DELAY_MS)
730
+ } else {
731
+ timer = win.setTimeout(function () {
732
+ if (done || env.doc.body !== body) return
733
+ shown = true
734
+ hold = holdAtLeast(minLoad())
735
+ self._remote[id] = 'loading'
736
+ openNow.call(self, id, props, opts)
737
+ }, LOAD_DELAY_MS)
738
+ }
493
739
 
494
- function fail() {
740
+ function stopTimer() { if (timer !== null && win.clearTimeout) win.clearTimeout(timer) }
741
+ // Only while this modal is on top: a late answer must not pull focus off another card.
742
+ function refocusIfTop() { var top = self.current(); if (top && top.id === id) self.refocus() }
743
+
744
+ function fail(err) {
495
745
  if (done) return
496
746
  done = true
497
- win.clearTimeout && win.clearTimeout(timer)
747
+ stopTimer()
498
748
  if (env.doc.body !== body) return
749
+ if (cfg.debug && err && err.studioModal) self._remoteReason[id] = err.message
499
750
  self._remote[id] = 'error'
500
751
  if (!shown) openNow.call(self, id, props, opts)
752
+ else refocusIfTop()
501
753
  }
502
754
 
503
755
  remote.load(cfg, id).then(function (markup) {
504
756
  if (done) return
505
- if (env.doc.body !== body) { done = true; win.clearTimeout && win.clearTimeout(timer); return }
757
+ if (env.doc.body !== body) { done = true; stopTimer(); return }
506
758
  if (!remote.insert(id, markup)) { fail(); return }
507
759
  done = true
508
- win.clearTimeout && win.clearTimeout(timer)
760
+ stopTimer()
509
761
  if (!shown) { openNow.call(self, id, props, opts); return }
510
762
  hold.then(function () {
511
- if (self._remote[id] === 'loading') delete self._remote[id]
763
+ if (self._remote[id] === 'loading') { delete self._remote[id]; refocusIfTop() }
512
764
  })
513
765
  }).catch(fail)
514
766
  }
515
767
 
516
- // Schedules the advertised prefetches once, when the browser idles.
768
+ // Schedules the advertised prefetches when the browser idles: once now, for
769
+ // the first load, and again after every Turbo visit. load() shares one
770
+ // request per id, so the first load's turbo:load adds no second fetch.
517
771
  if (env.doc && typeof env.doc.querySelector === 'function') {
518
772
  whenIdle(win, function () { remote.prefetch() })
773
+ if (typeof env.doc.addEventListener === 'function') {
774
+ env.doc.addEventListener('turbo:load', function () {
775
+ whenIdle(win, function () { remote.prefetch() })
776
+ })
777
+ }
519
778
  }
520
779
 
521
780
  function openNow(id, props, opts) {
@@ -219,10 +219,19 @@
219
219
  fetches a registered id the first time it opens and inserts its
220
220
  <template x-if> into the card. See the README, "Modals on demand". %>
221
221
  <% studio_remote_modals = Studio.modals.any? %>
222
+ <% if studio_remote_modals
223
+ studio_remote_session = respond_to?(:controller) && controller.respond_to?(:session) ? controller.session : nil
224
+ studio_remote_config = Studio.modals.client_config(
225
+ url_template: studio_modal_path(id: "0").delete_suffix("0") + "__id__",
226
+ logged_in: respond_to?(:logged_in?) && logged_in?,
227
+ session: Studio::Modals.session_marker(studio_remote_session),
228
+ debug: defined?(Rails) && Rails.respond_to?(:env) && (Rails.env.development? || Rails.env.test?)
229
+ )
230
+ end %>
222
231
  <template x-if="$store.modals.current()"
223
232
  data-studio-controller="modal-host"
224
233
  data-modal-host-store-value="modals"<% if studio_remote_modals %>
225
- data-modal-host-remote-value="<%= Studio.modals.client_config(url_template: studio_modal_path(id: "0").delete_suffix("0") + "__id__", logged_in: respond_to?(:logged_in?) && logged_in?).to_json %>"<% end %>>
234
+ data-modal-host-remote-value="<%= studio_remote_config.to_json %>"<% end %>>
226
235
  <%# Backdrop + card use CSS keyframe animations (NOT Alpine
227
236
  x-transition), all shipped in the inline style block above. On
228
237
  mount, the .modal-backdrop-mount + card mount classes fire fade-in
@@ -312,22 +321,28 @@
312
321
  <%# The on-demand states, keyed on $store.modals._remote[id], which only
313
322
  studio/modal_host sets and only for a registered id: 'loading' while a
314
323
  slow first fetch runs (the processing card, held MIN_LOAD_MS so it
315
- never flashes), 'error' when the fetch fails (the error card, whose
316
- button closes). Fetched registrations are appended after these. %>
324
+ never flashes; role="status", so a screen reader hears it politely),
325
+ 'error' when the fetch fails, times out (Studio.modals.fetch_timeout_ms)
326
+ or is refused (the error card: Try again re-runs the fetch in place,
327
+ Close closes). Close calls close() directly, so it works on a card
328
+ opened dismissible: false too, which Escape and the backdrop do not
329
+ close. Fetched registrations are appended after these. %>
317
330
  <% if studio_remote_modals %>
318
331
  <template x-if="$store.modals._remote && $store.modals._remote[$store.modals.current()?.id] === 'loading'">
319
- <div data-modal-remote-state="loading">
332
+ <div data-modal-remote-state="loading" role="status" aria-live="polite">
320
333
  <%= render "studio/modals/blocks/processing_card", title: "Loading…" %>
321
334
  </div>
322
335
  </template>
323
336
  <template x-if="$store.modals._remote && $store.modals._remote[$store.modals.current()?.id] === 'error'">
324
- <div data-modal-remote-state="error">
337
+ <div data-modal-remote-state="error" @studio-modal-retry="$store.modals.retryRemote()">
325
338
  <%= render "studio/modals/blocks/error_card",
326
339
  title: "Couldn't open this",
327
- message: "Check your connection and try again.",
340
+ message_key: "$store.modals.remoteErrorText()",
328
341
  icon_emoji: "⚠️",
329
- cta_label: "Close",
330
- cta_close: true %>
342
+ cta_label: "Try again",
343
+ cta_event: "studio-modal-retry",
344
+ secondary_label: "Close",
345
+ secondary_close: true %>
331
346
  </div>
332
347
  </template>
333
348
  <% end %>
@@ -93,14 +93,15 @@
93
93
  <h3 class="type-h4 text-heading">Table · sorted by member, a badge cell</h3>
94
94
  <div class="card overflow-hidden">
95
95
  <%= render Studio::TableComponent.new(rows: people, columns: columns, sort: "name", direction: "asc",
96
- sort_url: sort_url, min_width: 520) %>
96
+ sort_url: sort_url, min_width: 520, caption: "Team members, sorted by member") %>
97
97
  </div>
98
98
  </div>
99
99
 
100
100
  <div class="space-y-3" data-specimen="table-empty">
101
101
  <h3 class="type-h4 text-heading">Table · no rows</h3>
102
102
  <div class="card overflow-hidden">
103
- <%= render Studio::TableComponent.new(rows: [], columns: columns, sort_url: sort_url, empty: "No members yet.") %>
103
+ <%= render Studio::TableComponent.new(rows: [], columns: columns, sort_url: sort_url, empty: "No members yet.",
104
+ aria_label: "Team members") %>
104
105
  </div>
105
106
  </div>
106
107
  </div>
data/lib/studio/modals.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+
3
5
  module Studio
4
6
  # The on-demand modal registry: modals the shared host (studio/modals/_host)
5
7
  # fetches the first time they open, instead of the layout rendering every
@@ -22,11 +24,14 @@ module Studio
22
24
  # (one root element; see the README's "Modal host" rules).
23
25
  # auth: :any (default) or :user. A :user modal answers 401 to a
24
26
  # signed-out request, and is never prefetched for one.
25
- # prefetch: true fetches the modal when the browser idles after load, for a
26
- # modal that should open with no wait (the sign-in card).
27
+ # prefetch: true fetches the modal when the browser idles after load and
28
+ # after every Turbo visit, for a modal that should open with no
29
+ # wait (the sign-in card).
27
30
  # locals: the partial's locals. A Hash, a Symbol naming a helper the view
28
31
  # answers (Turf's existing *_modal_locals helpers), or a callable
29
- # taking the view. Resolved per request, inside the view.
32
+ # taking the view. Resolved per request, inside the view of
33
+ # Studio::ModalsController: helpers and the session are there,
34
+ # the page's instance variables (@contest) are not.
30
35
  module Modals
31
36
  AUTH_LEVELS = %i[any user].freeze
32
37
 
@@ -54,8 +59,23 @@ module Studio
54
59
  end
55
60
 
56
61
  class Registry
62
+ # How long studio/modal_host waits for a fragment before it gives up and
63
+ # shows the error card (with Try again and Close), in milliseconds.
64
+ DEFAULT_FETCH_TIMEOUT_MS = 8000
65
+
66
+ # Settable from the initializer: Studio.modals.fetch_timeout_ms = 12_000.
67
+ attr_reader :fetch_timeout_ms
68
+
57
69
  def initialize
58
70
  @entries = {}
71
+ @fetch_timeout_ms = DEFAULT_FETCH_TIMEOUT_MS
72
+ end
73
+
74
+ def fetch_timeout_ms=(value)
75
+ ms = Integer(value)
76
+ raise ArgumentError, "Studio.modals.fetch_timeout_ms must be positive, got #{value.inspect}" unless ms.positive?
77
+
78
+ @fetch_timeout_ms = ms
59
79
  end
60
80
 
61
81
  def register(id, partial:, auth: :any, prefetch: false, locals: nil)
@@ -85,16 +105,48 @@ module Studio
85
105
  # What the host template advertises to studio/modal_host. Every id, so a
86
106
  # signed-out open of a :user modal still reaches the endpoint and shows
87
107
  # the error card; prefetch only what this viewer may load.
88
- def client_config(url_template:, logged_in:)
108
+ #
109
+ # session: the marker studio/modal_host keys its fragment cache on (see
110
+ # Modals.session_marker). debug: true in development and test, where a
111
+ # refused fragment names its reason on the error card.
112
+ def client_config(url_template:, logged_in:, session: nil, debug: false)
89
113
  {
90
114
  url: url_template,
91
115
  ids: ids,
92
- prefetch: entries.select { |e| e.prefetch && (!e.user_only? || logged_in) }.map(&:id)
116
+ prefetch: entries.select { |e| e.prefetch && (!e.user_only? || logged_in) }.map(&:id),
117
+ timeout_ms: fetch_timeout_ms,
118
+ session: session.to_s,
119
+ debug: debug == true
93
120
  }
94
121
  end
95
122
  end
96
123
  end
97
124
 
125
+ module Modals
126
+ # THE SESSION MARKER. A fetched fragment can carry the viewer's data and a
127
+ # form's CSRF token, and studio/modal_host keeps it for the page's life. A
128
+ # Turbo visit across sign-in, sign-out or a user switch keeps that page, so
129
+ # the browser compares this marker on every open and every turbo:load and
130
+ # drops its cache when it changes.
131
+ #
132
+ # It digests two things the request already has:
133
+ # - the session id, which reset_session rotates (sign-out, and the
134
+ # sign-ins that reset), and with it the session's CSRF secret: a cached
135
+ # form token stops being valid exactly when this changes;
136
+ # - the signed-in user id under Studio.session_key, which a sign-in that
137
+ # does NOT reset the session (an OAuth or wallet sign-in) still changes.
138
+ # Digested so neither value reaches the page. A request with no session
139
+ # (a view rendered outside a controller) answers the digest of nothing:
140
+ # a constant, so the cache simply lives for the page, as before.
141
+ def self.session_marker(session)
142
+ sid = session.respond_to?(:id) ? session.id.to_s : ""
143
+ uid = session.respond_to?(:[]) ? session[Studio.session_key].to_s : ""
144
+ Digest::SHA256.hexdigest("#{sid}|#{uid}")[0, 16]
145
+ rescue StandardError
146
+ Digest::SHA256.hexdigest("|")[0, 16]
147
+ end
148
+ end
149
+
98
150
  def self.modals
99
151
  @modals ||= Modals::Registry.new
100
152
  end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Studio::SystemTestCase: the one browser-test base every host app inherits.
4
+ #
5
+ # A host's test/application_system_test_case.rb becomes:
6
+ #
7
+ # require "test_helper"
8
+ # require "studio/system_test_case"
9
+ #
10
+ # class ApplicationSystemTestCase < Studio::SystemTestCase
11
+ # driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1000]
12
+ # end
13
+ #
14
+ # It owns the harness bugs that were fixed app by app, so a fix ships once, in
15
+ # the gem, to every app. Detail and the customization API: docs/SYSTEM_TESTS.md.
16
+ #
17
+ # lib/studio.rb does NOT require this file. It needs Capybara and
18
+ # selenium-webdriver, which a host bundles in its test group and the gem does
19
+ # not declare. The host requires it from its system-test base only.
20
+ require "action_dispatch/system_test_case"
21
+ require "capybara/selenium/driver"
22
+
23
+ module Studio
24
+ class SystemTestCase < ActionDispatch::SystemTestCase
25
+ # The harness hooks this base runs, by name. A subclass opts out of one by
26
+ # name, with a reason (skip_harness_hook); every other hook still runs.
27
+ HOOKS = {
28
+ reset_window: "setup: resize the window to desktop_size. The browser outlives " \
29
+ "the test, so a resize leaks into the next test (commercial-welding " \
30
+ "went red under one seed).",
31
+ restore_script_timeout: "around evaluate_async_script: give back the script " \
32
+ "timeout. Capybara 3.40.0 sets it to default_max_wait_time " \
33
+ "(2s) and never restores it (voicebox ContrastSystemTest).",
34
+ clear_emulation: "teardown: clear the DevTools device-metrics and emulated-media " \
35
+ "overrides (phone_viewport!, a dark-mode or reduced-motion test), " \
36
+ "so neither leaks into the next test."
37
+ }.freeze
38
+
39
+ # Selenium's stock read budget is Net::HTTP's 60s. A cold Chrome launch on a
40
+ # slow CI runner can pass it, and the run dies with Net::ReadTimeout in
41
+ # whichever test the seed put first (hub PR #2494).
42
+ DEFAULT_READ_TIMEOUT = 180
43
+ DEFAULT_DESKTOP_SIZE = [1400, 1000].freeze
44
+
45
+ # Thread-local switch the restore_script_timeout hook reads. The prepend on
46
+ # Capybara's driver is global; this key makes the opt-out per test class.
47
+ SCRIPT_TIMEOUT_KEY = :studio_system_test_restore_script_timeout
48
+
49
+ # The window every test starts in. Set it through driven_by's screen_size,
50
+ # so the browser's launch size and the reset size are one number.
51
+ class_attribute :desktop_size, instance_accessor: false, default: DEFAULT_DESKTOP_SIZE
52
+ # The phone viewport phone_viewport! and assert_viewport_width default to.
53
+ class_attribute :phone_width, instance_accessor: false, default: 390
54
+ class_attribute :phone_height, instance_accessor: false, default: 844
55
+ # { hook name => reason } for the hooks this class opted out of.
56
+ class_attribute :skipped_harness_hooks, instance_accessor: false, default: {}.freeze
57
+
58
+ class << self
59
+ # Rails' driven_by, with two additions. screen_size also becomes
60
+ # desktop_size, the size reset_window restores. A Selenium driver gets a
61
+ # 180s read timeout unless options: names its own :timeout.
62
+ def driven_by(driver = :selenium, using: :headless_chrome, screen_size: DEFAULT_DESKTOP_SIZE,
63
+ options: {}, &capabilities)
64
+ self.desktop_size = Array(screen_size).dup.freeze
65
+ options = { timeout: DEFAULT_READ_TIMEOUT }.merge(options) if driver.to_sym == :selenium
66
+ super(driver, using: using, screen_size: screen_size, options: options, &capabilities)
67
+ end
68
+
69
+ # Opt this class (and its subclasses) out of one harness hook. The name
70
+ # must be in HOOKS and the reason must say why; both are logged once, so a
71
+ # skipped fix is never silent.
72
+ def skip_harness_hook(name, reason:)
73
+ name = name.to_sym
74
+ unless HOOKS.key?(name)
75
+ raise ArgumentError, "unknown harness hook #{name.inspect}; the hooks are #{HOOKS.keys.map(&:inspect).join(', ')}"
76
+ end
77
+ raise ArgumentError, "skip_harness_hook #{name.inspect} needs a reason:" if reason.to_s.strip.empty?
78
+
79
+ self.skipped_harness_hooks = skipped_harness_hooks.merge(name => reason.to_s.strip).freeze
80
+ warn "[Studio::SystemTestCase] #{self.name || inspect} skips the #{name} hook: #{reason.to_s.strip}"
81
+ end
82
+
83
+ def harness_hook?(name)
84
+ !skipped_harness_hooks.key?(name.to_sym)
85
+ end
86
+
87
+ # Read by the driver prepend below. nil (no Studio test running on this
88
+ # thread) means on: the restore is a pure bug fix.
89
+ def restore_script_timeout?
90
+ Thread.current[SCRIPT_TIMEOUT_KEY] != false
91
+ end
92
+ end
93
+
94
+ # Capybara 3.40.0's evaluate_async_script sets the browser's script timeout
95
+ # to Capybara.default_max_wait_time (2s) and never sets it back. The browser
96
+ # outlives the test, so every later execute_script in the run has 2s, not the
97
+ # driver's 30s. This gives back the timeout it borrowed (voicebox PR #35).
98
+ module RestoresScriptTimeout
99
+ def evaluate_async_script(script, *args)
100
+ return super unless Studio::SystemTestCase.restore_script_timeout?
101
+
102
+ timeouts = browser.manage.timeouts
103
+ previous = timeouts.script_timeout
104
+ super
105
+ ensure
106
+ timeouts.script_timeout = previous if previous
107
+ end
108
+ end
109
+ Capybara::Selenium::Driver.prepend(RestoresScriptTimeout)
110
+
111
+ driven_by :selenium, using: :headless_chrome, screen_size: DEFAULT_DESKTOP_SIZE
112
+
113
+ # The base's setup runs before any setup a subclass declares, so an app's
114
+ # own setup always starts in the reset window.
115
+ setup do
116
+ Thread.current[SCRIPT_TIMEOUT_KEY] = self.class.harness_hook?(:restore_script_timeout)
117
+ reset_window if self.class.harness_hook?(:reset_window) && browser_window?
118
+ end
119
+
120
+ # Teardown callbacks run in reverse: a subclass's teardown runs first, then
121
+ # this one.
122
+ teardown do
123
+ clear_emulation if self.class.harness_hook?(:clear_emulation) && devtools?
124
+ ensure
125
+ Thread.current[SCRIPT_TIMEOUT_KEY] = nil
126
+ end
127
+
128
+ def reset_window
129
+ page.driver.browser.manage.window.resize_to(*self.class.desktop_size)
130
+ end
131
+
132
+ # A real phone-width viewport. Headless Chrome will not size its window
133
+ # under 500px, so resize_to(390, ...) gives a 500px page and a "390px" check
134
+ # passes at the wrong width. DevTools device-metrics emulation sets the
135
+ # viewport itself. After each visit, before you measure, call
136
+ # assert_viewport_width. The clear_emulation hook clears it in teardown.
137
+ def phone_viewport!(width = self.class.phone_width, height = self.class.phone_height)
138
+ page.driver.browser.execute_cdp("Emulation.setDeviceMetricsOverride",
139
+ width: width, height: height, deviceScaleFactor: 1, mobile: true)
140
+ end
141
+
142
+ # Reads clientWidth, not innerWidth: under mobile emulation innerWidth grows
143
+ # to fit a page that overflows, so a check against it passes vacuously.
144
+ # Phone widths only: a desktop Linux Chrome takes 15px of clientWidth for
145
+ # its scrollbar.
146
+ def assert_viewport_width(width = self.class.phone_width)
147
+ assert_equal width, evaluate_script("document.documentElement.clientWidth"), "the viewport is #{width}px"
148
+ end
149
+
150
+ # Clears both DevTools overrides a test may set: the phone viewport and the
151
+ # emulated media (prefers-color-scheme, prefers-reduced-motion, print).
152
+ def clear_emulation
153
+ page.driver.browser.execute_cdp("Emulation.clearDeviceMetricsOverride")
154
+ page.driver.browser.execute_cdp("Emulation.setEmulatedMedia", media: "", features: [])
155
+ end
156
+
157
+ private
158
+
159
+ # A real browser window: Selenium, not rack_test. A subclass driven by
160
+ # rack_test skips the browser hooks without opting out.
161
+ def browser_window?
162
+ page.driver.respond_to?(:browser) && page.driver.browser.respond_to?(:manage)
163
+ end
164
+
165
+ # A Chromium browser that takes DevTools commands.
166
+ def devtools?
167
+ browser_window? && page.driver.browser.respond_to?(:execute_cdp)
168
+ end
169
+ end
170
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.143.2"
2
+ VERSION = "0.144.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.143.2
4
+ version: 0.144.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -1022,6 +1022,7 @@ files:
1022
1022
  - lib/studio/survey.rb
1023
1023
  - lib/studio/survey/breakdown.rb
1024
1024
  - lib/studio/survey/export.rb
1025
+ - lib/studio/system_test_case.rb
1025
1026
  - lib/studio/tailwind_entry.rb
1026
1027
  - lib/studio/theme_resolver.rb
1027
1028
  - lib/studio/tricks.rb