studio-engine 0.137.0 → 0.138.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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +27 -1
  4. data/app/assets/tailwind/studio_engine/engine.css +228 -0
  5. data/app/controllers/concerns/studio/connection_flow.rb +1 -1
  6. data/app/controllers/sessions_controller.rb +12 -3
  7. data/app/controllers/studio/integrations_controller.rb +43 -0
  8. data/app/controllers/studio/local_reviews_controller.rb +12 -0
  9. data/app/javascript/studio/carousel.js +66 -0
  10. data/app/javascript/studio/controllers/carousel_controller.js +203 -1
  11. data/app/models/concerns/studio/account.rb +143 -0
  12. data/app/models/concerns/studio/omniauth_user.rb +9 -17
  13. data/app/models/error_log.rb +14 -5
  14. data/app/views/studio/_carousel.html.erb +58 -14
  15. data/app/views/studio/integrations/_card.html.erb +50 -0
  16. data/app/views/studio/integrations/index.html.erb +22 -0
  17. data/app/views/studio/integrations/show.html.erb +24 -0
  18. data/app/views/style/_tricks.html.erb +15 -0
  19. data/config/studio_tricks.yml +7 -0
  20. data/db/migrate/20261006120001_ensure_error_logs_table.rb +3 -1
  21. data/db/migrate/20261006120002_ensure_theme_settings_table.rb +3 -2
  22. data/db/migrate/20261108160000_require_slugs_on_engine_tables.rb +119 -0
  23. data/db/migrate/20261108160001_add_engine_state_checks.rb +94 -0
  24. data/db/migrate/20261108170000_add_users_role_known_check.rb +69 -0
  25. data/lib/generators/studio/connections/connections_generator.rb +10 -0
  26. data/lib/studio/connections.rb +53 -12
  27. data/lib/studio/integrations/egnyte.rb +69 -0
  28. data/lib/studio/integrations/microsoft_365.rb +63 -0
  29. data/lib/studio/integrations/quickbooks.rb +86 -0
  30. data/lib/studio/integrations.rb +265 -0
  31. data/lib/studio/node_tooling/README.md +9 -1
  32. data/lib/studio/node_tooling/e2e_helpers.js +93 -1
  33. data/lib/studio/node_tooling/playwright_base.js +51 -4
  34. data/lib/studio/roles.rb +17 -0
  35. data/lib/studio/version.rb +1 -1
  36. data/lib/studio.rb +59 -1
  37. metadata +15 -2
@@ -49,9 +49,22 @@
49
49
  // The pause button (WCAG 2.2.2) stops it until pressed again; under reduced motion
50
50
  // the row starts stopped. A move by autoplay writes nothing to the status line
51
51
  // (APG: a rotating carousel does not announce). Without loop it stops at the end.
52
+ //
53
+ // A CASCADE row (class carousel-cascade) does not scroll. Its slides share one grid
54
+ // cell and each wears data-cascade-step, its place from the open slide (cascadeStep,
55
+ // cascadeSlot); engine.css lays the 3D stack out from it and transitions it. The
56
+ // open slide carries data-current. Every slide is a stop, so the arrows, dots, keys
57
+ // and autoplay move it as they move a spotlight, through the same move() and
58
+ // scrollToStop(). A swipe or a drag (pointer events, past DRAG_THRESHOLD) moves it
59
+ // too; a click on a side slide brings that slide to the front and follows no link,
60
+ // and keyboard focus landing in a side slide does the same, so Tab walks the set. A
61
+ // looping cascade takes no clones: the steps wrap round the set, and a slide that
62
+ // crosses the stack round the seam (cascadeJumps) takes no glide. A focused slide
63
+ // that leaves the window hands focus to the track, so the keys keep working.
52
64
  import { Controller } from "@hotwired/stimulus"
53
65
  import {
54
- CLONE_STRIP, TOLERANCE, autoplayRunning, autoplaySeconds, autoplayStop, centerStops, cloneCount, cloneLayout,
66
+ CLONE_STRIP, TOLERANCE, autoplayRunning, autoplaySeconds, autoplayStop, cascadeJumps, cascadeReach, cascadeSlot,
67
+ cascadeStep, cascadeSteps, cascadeTarget, centerStops, cloneCount, cloneLayout, dragSteps,
55
68
  currentStop, hiddenEnds, idleDelay, initialReasons, keyAction, loopPositionText, loopStop, nearestVirtual, ownsKey,
56
69
  perPage, positionText, seamJump, startStop, stops, targetStop, toggleLabel, withReason, wrapIndex
57
70
  } from "studio/carousel"
@@ -66,8 +79,13 @@ export default class extends Controller {
66
79
  this.settle = this.settle.bind(this)
67
80
  this.clickClone = this.clickClone.bind(this)
68
81
  this.loop = this.element.hasAttribute("data-carousel-loop")
82
+ this.cascade = this.element.classList.contains("carousel-cascade")
69
83
  this.cloned = 0
70
84
  this.removeClones()
85
+ if (this.cascade) {
86
+ this.connectCascade()
87
+ return
88
+ }
71
89
  this.scrollEnd = "onscrollend" in window
72
90
  this.trackTarget.addEventListener("scroll", this.schedule, { passive: true })
73
91
  if (this.scrollEnd) this.trackTarget.addEventListener("scrollend", this.settle, { passive: true })
@@ -83,6 +101,179 @@ export default class extends Controller {
83
101
  this.startAutoplay()
84
102
  }
85
103
 
104
+ // -- the cascade --------------------------------------------------------------
105
+
106
+ connectCascade() {
107
+ const slides = this.slideTargets
108
+ const marked = slides.findIndex((slide) => slide.hasAttribute("data-current"))
109
+ this.active = marked >= 0 ? marked : 0
110
+ // A #fragment inside a slide opens on that slide.
111
+ const hash = window.location?.hash
112
+ if (hash && hash.length > 1) {
113
+ let target = null
114
+ try { target = document.getElementById(decodeURIComponent(hash.slice(1))) } catch { target = null }
115
+ const index = target ? slides.findIndex((slide) => slide.contains(target)) : -1
116
+ if (index >= 0) this.active = index
117
+ }
118
+ this.cascadeListeners = [
119
+ ["pointerdown", (event) => this.dragStart(event)],
120
+ ["pointermove", (event) => this.dragMove(event)],
121
+ ["pointerup", (event) => this.dragEnd(event)],
122
+ ["pointercancel", () => this.dragCancel()],
123
+ ["click", (event) => this.clickSide(event), true],
124
+ ["focusin", (event) => this.focusSide(event)],
125
+ ["dragstart", (event) => event.preventDefault()]
126
+ ]
127
+ this.cascadeListeners.forEach(([type, handler, capture]) => this.trackTarget.addEventListener(type, handler, { capture: !!capture }))
128
+ if (typeof ResizeObserver === "function") {
129
+ this.observer = new ResizeObserver(this.schedule)
130
+ this.observer.observe(this.trackTarget)
131
+ }
132
+ this.element.setAttribute("data-carousel-ready", "")
133
+ this.sync()
134
+ this.startAutoplay()
135
+ }
136
+
137
+ disconnectCascade() {
138
+ this.cascadeListeners?.forEach(([type, handler, capture]) => this.trackTarget.removeEventListener(type, handler, { capture: !!capture }))
139
+ this.cascadeListeners = null
140
+ this.dragCancel()
141
+ }
142
+
143
+ // The reach CSS lays out: 1 on a three-slide cascade or a narrow region, else 2.
144
+ reach() {
145
+ const css = parseFloat(getComputedStyle(this.trackTarget).getPropertyValue("--carousel-cascade-reach"))
146
+ return css > 0 ? css : cascadeReach(this.element.dataset.carouselVisible)
147
+ }
148
+
149
+ syncCascade() {
150
+ const total = this.slideTargets.length
151
+ this.page = 1
152
+ this.positions = Array.from({ length: Math.max(1, total) }, (_, index) => index)
153
+ this.full = null
154
+ this.active = Math.min(Math.max(0, this.active || 0), Math.max(0, total - 1))
155
+ this.current = this.virtual = this.active
156
+ this.element.toggleAttribute("data-carousel-static", total < 2)
157
+ const wraps = this.loop && total > 1
158
+ if (this.hasPrevTarget) this.prevTarget.setAttribute("aria-disabled", String(!wraps && this.active === 0))
159
+ if (this.hasNextTarget) this.nextTarget.setAttribute("aria-disabled", String(!wraps && this.active >= total - 1))
160
+ const steps = cascadeSteps(total, this.active, this.loop)
161
+ const jumps = this.laid ? cascadeJumps(this.laid, steps, this.active) : steps.map(() => false)
162
+ this.slideTargets.forEach((slide, index) => {
163
+ slide.setAttribute("data-cascade-step", cascadeSlot(steps[index]))
164
+ slide.toggleAttribute("data-cascade-jump", jumps[index])
165
+ slide.toggleAttribute("data-current", index === this.active)
166
+ })
167
+ this.laid = steps
168
+ this.dotTargets.forEach((dot, index) => {
169
+ dot.hidden = index >= total
170
+ if (index === this.active) dot.setAttribute("aria-current", "true")
171
+ else dot.removeAttribute("aria-current")
172
+ })
173
+ }
174
+
175
+ // Brings slide `index` to the front. A focused slide that leaves the window hands
176
+ // focus to the track (a hidden slide cannot hold it), so the keys keep working.
177
+ activate(index) {
178
+ const total = this.slideTargets.length
179
+ if (!(total > 0)) return
180
+ this.active = Math.min(total - 1, Math.max(0, index))
181
+ this.sync()
182
+ const focused = this.slideTargets.findIndex((slide) => slide.contains(document.activeElement))
183
+ if (focused >= 0 && Math.abs(cascadeStep(focused, this.active, total, this.loop)) > this.reach()) {
184
+ this.trackTarget.focus({ preventScroll: true })
185
+ }
186
+ }
187
+
188
+ slideAt(target) {
189
+ const slide = target?.closest?.(".carousel-slide")
190
+ return slide && this.trackTarget.contains(slide) ? this.slideTargets.indexOf(slide) : -1
191
+ }
192
+
193
+ // A press on a side slide: the click that follows brings it to the front and opens
194
+ // nothing. The slide is read at the press, so a link whose focus already moved the
195
+ // stack still counts as a side slide's.
196
+ clickSide(event) {
197
+ if (this.dragged) {
198
+ this.dragged = false
199
+ event.preventDefault()
200
+ event.stopPropagation()
201
+ return
202
+ }
203
+ const index = this.pressed ?? this.slideAt(event.target)
204
+ this.pressed = null
205
+ if (index < 0 || index === this.active) return
206
+ event.preventDefault()
207
+ event.stopPropagation()
208
+ this.interacted()
209
+ this.scrollToStop(index)
210
+ }
211
+
212
+ // Keyboard focus in a side slide brings it to the front, so Tab walks the set. A
213
+ // mouse press focuses without :focus-visible and leaves the move to its click.
214
+ focusSide(event) {
215
+ const index = this.slideAt(event.target)
216
+ if (index < 0 || index === this.active) return
217
+ let keyboard = true
218
+ try { keyboard = event.target.matches(":focus-visible") } catch { keyboard = true }
219
+ if (!keyboard) return
220
+ this.interacted()
221
+ this.scrollToStop(index, { announce: false })
222
+ }
223
+
224
+ dragStart(event) {
225
+ if (event.button > 0 || this.drag) return
226
+ const index = this.slideAt(event.target)
227
+ this.pressed = index >= 0 && index !== this.active ? index : null
228
+ this.dragged = false
229
+ this.drag = { id: event.pointerId, x: event.clientX, y: event.clientY, live: false }
230
+ }
231
+
232
+ dragMove(event) {
233
+ const drag = this.drag
234
+ if (!drag || event.pointerId !== drag.id) return
235
+ const dx = event.clientX - drag.x
236
+ if (!drag.live) {
237
+ if (Math.abs(dx) < 8 || Math.abs(dx) < Math.abs(event.clientY - drag.y)) return
238
+ drag.live = true
239
+ // Captured only once it is a drag, so a plain click still lands on its link.
240
+ try { this.trackTarget.setPointerCapture(drag.id) } catch { /* the pointer is gone */ }
241
+ this.element.setAttribute("data-carousel-dragging", "")
242
+ }
243
+ this.trackTarget.style.setProperty("--carousel-cascade-drag", `${Math.round(dx * 0.35)}px`)
244
+ }
245
+
246
+ dragEnd(event) {
247
+ const drag = this.drag
248
+ if (!drag || event.pointerId !== drag.id) return
249
+ const live = drag.live
250
+ this.dragCancel()
251
+ if (!live) return
252
+ const by = dragSteps(event.clientX - drag.x, this.stepWidth())
253
+ this.pressed = null
254
+ this.dragged = true
255
+ // The click a drag ends in is swallowed by clickSide; a touch drag fires none.
256
+ setTimeout(() => { this.dragged = false }, 0)
257
+ if (by === 0) return
258
+ this.interacted()
259
+ const total = this.slideTargets.length
260
+ const to = cascadeTarget(this.active, by, total, this.loop)
261
+ if (to !== this.active) this.scrollToStop(to)
262
+ }
263
+
264
+ dragCancel() {
265
+ this.drag = null
266
+ this.element?.removeAttribute("data-carousel-dragging")
267
+ this.trackTarget?.style.removeProperty("--carousel-cascade-drag")
268
+ }
269
+
270
+ // The width a drag crosses per step: the open slide's, or a fifth of the row.
271
+ stepWidth() {
272
+ const open = this.slideTargets[this.active]
273
+ const width = open ? open.getBoundingClientRect().width : 0
274
+ return width > 0 ? width * 0.6 : this.trackTarget.clientWidth / 5
275
+ }
276
+
86
277
  openAtStart() {
87
278
  const start = Number(this.element.dataset.carouselStart)
88
279
  if (!(start > 0)) return
@@ -101,6 +292,7 @@ export default class extends Controller {
101
292
  }
102
293
 
103
294
  disconnect() {
295
+ if (this.cascade) this.disconnectCascade()
104
296
  this.trackTarget.removeEventListener("scroll", this.schedule)
105
297
  this.trackTarget.removeEventListener("scrollend", this.settle)
106
298
  this.trackTarget.removeEventListener("click", this.clickClone)
@@ -332,6 +524,10 @@ export default class extends Controller {
332
524
  }
333
525
 
334
526
  sync() {
527
+ if (this.cascade) {
528
+ this.syncCascade()
529
+ return
530
+ }
335
531
  const track = this.trackTarget
336
532
  const { positions, page } = this.measure()
337
533
  const ends = hiddenEnds(track)
@@ -412,6 +608,12 @@ export default class extends Controller {
412
608
  // row the index is virtual: a clone's place glides there and settle() jumps home,
413
609
  // but under reduced motion the row goes straight to the real slide.
414
610
  scrollToStop(index, { announce = true } = {}) {
611
+ if (this.cascade) {
612
+ if (!(index >= 0 && index < this.slideTargets.length)) return
613
+ this.activate(index)
614
+ if (announce && this.hasStatusTarget) this.statusTarget.textContent = positionText(index, 1, this.slideTargets.length)
615
+ return
616
+ }
415
617
  const reduce = window.matchMedia?.(REDUCE).matches
416
618
  if (this.cloned) {
417
619
  const total = this.positions.length
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # The engine-owned account contract (database audit 2026-11-07, section 3,
5
+ # "Recommended primitive: Studio::Account"). One copy of the rules nine apps
6
+ # typed by hand:
7
+ #
8
+ # class User < ApplicationRecord
9
+ # include Studio::Account
10
+ # studio_roles %w[viewer admin] # optional: narrow, never widen
11
+ #
12
+ # def name_slug = name.to_s.parameterize # Sluggable's, the host's own
13
+ # end
14
+ #
15
+ # It gives the host:
16
+ #
17
+ # * `admin?` (role "admin") and a default `display_name`;
18
+ # * `normalizes :email`: stripped, downcased, and a blank address stored as
19
+ # NULL, so "" and " " never collide on the unique index or match a lookup;
20
+ # * `validates :role, inclusion:` against Studio::Roles, or the host's
21
+ # narrower list, with the default role filled on create;
22
+ # * Sluggable and Studio::OmniauthUser (the engine's Google find-or-create,
23
+ # with its race rescue);
24
+ # * one safe email lookup, `User.account_for_email(address)` and, for code
25
+ # that holds a model class rather than including this,
26
+ # `Studio::Account.find_by_email(User, address)`.
27
+ #
28
+ # THE NAME. The audit named the concern Studio::User. It cannot be: inside
29
+ # `module Studio`, a bare `User` resolves to Studio::User before ::User, and
30
+ # the engine reads the host's User bare in Studio::ErrorHandling#current_user,
31
+ # Studio::LinkConsumption and Studio::LocalReviewsController. A Studio::User
32
+ # constant would hand those the concern instead of the model.
33
+ # test/integration/studio_account_test.rb holds that line.
34
+ #
35
+ # THE LOOKUP RULE (the account-takeover class the hub and Turf fixed on
36
+ # 2026-11-08). `find_by(email: nil)` matches ANY account without an email:
37
+ # every wallet and guest sign-up. So a blank address finds nobody, the match
38
+ # is case-insensitive (`LOWER(email)`), and a lookup that LINKS a Google
39
+ # identity to the account it finds takes Google's verification first.
40
+ module Account
41
+ extend ActiveSupport::Concern
42
+
43
+ included do
44
+ include Sluggable
45
+ include Studio::OmniauthUser
46
+
47
+ class_attribute :studio_role_list, instance_writer: false, default: Studio::Roles
48
+
49
+ normalizes :email, with: ->(address) { Studio::Account.normalize_email(address) }
50
+
51
+ before_validation :studio_fill_default_role, on: :create
52
+ validates :role, inclusion: { in: ->(record) { record.class.studio_role_list } },
53
+ if: -> { has_attribute?(:role) }
54
+ end
55
+
56
+ class_methods do
57
+ # Narrows the roles this app accepts. Every one must be in Studio::Roles:
58
+ # a host cannot invent a role the engine does not know.
59
+ def studio_roles(list)
60
+ roles = Array(list).map(&:to_s).uniq
61
+ raise ArgumentError, "studio_roles needs at least one role" if roles.empty?
62
+
63
+ unknown = roles - Studio::Roles
64
+ if unknown.any?
65
+ raise ArgumentError, "studio_roles may only narrow Studio::Roles (#{Studio::Roles.join(', ')}); " \
66
+ "unknown: #{unknown.join(', ')}. Add a role to the engine, for every app."
67
+ end
68
+
69
+ self.studio_role_list = roles.freeze
70
+ end
71
+
72
+ # The role a new account takes: viewer, or the first of a list without it.
73
+ def studio_default_role
74
+ studio_role_list.include?(Studio::DEFAULT_ROLE) ? Studio::DEFAULT_ROLE : studio_role_list.first
75
+ end
76
+
77
+ # The account holding this address, or nil. A blank address finds nobody.
78
+ def account_for_email(address)
79
+ Studio::Account.find_by_email(self, address)
80
+ end
81
+ end
82
+
83
+ class << self
84
+ # " Mia@Example.COM " -> "mia@example.com"; blank -> nil.
85
+ def normalize_email(value)
86
+ email = value.to_s.strip.downcase
87
+ email.empty? ? nil : email
88
+ end
89
+
90
+ # The one safe lookup by address, for any model with an `email` column.
91
+ # Blank finds nobody; the compare is LOWER(email), so a row stored before
92
+ # `normalizes` still matches.
93
+ def find_by_email(model, address)
94
+ email = normalize_email(address)
95
+ return nil if email.nil?
96
+ return nil unless model.respond_to?(:attribute_names) && model.attribute_names.include?("email")
97
+
98
+ model.where(model.arel_table[:email].lower.eq(email)).first
99
+ end
100
+
101
+ # The account a lost race left behind: a concurrent sign-in made the row
102
+ # this one collided with. The Google id always identifies it; the address
103
+ # only when Google verified it, because an unverified address proves
104
+ # nothing about who holds the mailbox. Nobody found: the collision is
105
+ # logged with ErrorLog.capture! and the answer is nil, never a stranger.
106
+ def race_winner(model, provider:, uid:, email: nil, email_verified: false, error: nil)
107
+ found = model.find_by(provider: provider, uid: uid) if provider.present? && uid.present?
108
+ found ||= find_by_email(model, email) if email_verified
109
+ return found if found
110
+
111
+ log_unmatched_race(error)
112
+ nil
113
+ end
114
+
115
+ private
116
+
117
+ def log_unmatched_race(error)
118
+ return unless error
119
+
120
+ Rails.logger.warn("[studio account] sign-in collided with nobody to find: #{error.class}: #{error.message.to_s[0, 300]}")
121
+ ErrorLog.capture!(error) if defined?(::ErrorLog) && ::ErrorLog.respond_to?(:capture!)
122
+ rescue StandardError => e
123
+ Rails.logger.warn("[studio account] could not log the collision: #{e.class}")
124
+ end
125
+ end
126
+
127
+ def admin?
128
+ role.to_s == "admin"
129
+ end
130
+
131
+ def display_name
132
+ try(:name).presence || try(:email).to_s.split("@").first.presence || "User"
133
+ end
134
+
135
+ private
136
+
137
+ def studio_fill_default_role
138
+ return unless has_attribute?(:role) && role.blank?
139
+
140
+ self.role = self.class.studio_default_role
141
+ end
142
+ end
143
+ end
@@ -69,15 +69,14 @@ module Studio
69
69
  create_with_free_slug(model, attributes, email_verified: email_verified)
70
70
  end
71
71
 
72
+ # Kept under these names for callers that predate Studio::Account, which
73
+ # now owns the one safe lookup: a blank address finds nobody.
72
74
  def normalize_email(value)
73
- email = value.to_s.strip.downcase
74
- email.empty? ? nil : email
75
+ Studio::Account.normalize_email(value)
75
76
  end
76
77
 
77
78
  def find_by_email(model, email)
78
- return nil unless column?(model, "email")
79
-
80
- model.where(model.arel_table[:email].lower.eq(email)).first
79
+ Studio::Account.find_by_email(model, email)
81
80
  end
82
81
 
83
82
  private
@@ -99,13 +98,15 @@ module Studio
99
98
  # verified it, the same rule as step 2 above. An unverified address
100
99
  # that collides with someone else's row hands that row to nobody.
101
100
  found = model.find_by(provider: assignable[:provider], uid: assignable[:uid])
102
- found ||= find_by_email(model, assignable[:email]) if email_verified && assignable[:email]
101
+ found ||= find_by_email(model, assignable[:email]) if email_verified
103
102
  return found if found
104
103
  end
105
104
  end
106
105
 
107
- log_unmatched_collision(last_error)
108
- nil
106
+ # Nobody to find on either attempt: Studio::Account.race_winner logs it.
107
+ Studio::Account.race_winner(model, provider: attributes[:provider], uid: attributes[:uid],
108
+ email: attributes[:email], email_verified: email_verified,
109
+ error: last_error)
109
110
  end
110
111
 
111
112
  def slug_needs_fallback?(model, record)
@@ -126,15 +127,6 @@ module Studio
126
127
  record.define_singleton_method(:name_slug) { id ? "user-#{id}" : "user-#{token}" }
127
128
  end
128
129
 
129
- def log_unmatched_collision(error)
130
- return unless error
131
-
132
- Rails.logger.warn("[omniauth] Google sign-in collided with nobody to find: #{error.class}: #{error.message.to_s[0, 300]}")
133
- ErrorLog.capture!(error) if defined?(::ErrorLog) && ::ErrorLog.respond_to?(:capture!)
134
- rescue StandardError => e
135
- Rails.logger.warn("[omniauth] could not log the collision: #{e.class}")
136
- end
137
-
138
130
  def column?(model, name)
139
131
  model.respond_to?(:attribute_names) && model.attribute_names.include?(name)
140
132
  end
@@ -1,11 +1,13 @@
1
1
  class ErrorLog < ApplicationRecord
2
+ # `slug` is NOT NULL and unique (RequireSlugsOnEngineTables), so every insert
3
+ # carries one, including a host's bare `ErrorLog.create!`. Sluggable writes a
4
+ # unique placeholder before the insert and settles it to `error-log-<id>` right
5
+ # after, inside the create, once the id exists.
6
+ include Sluggable
7
+
2
8
  belongs_to :target, polymorphic: true, optional: true
3
9
  belongs_to :parent, polymorphic: true, optional: true
4
10
 
5
- def to_param
6
- slug
7
- end
8
-
9
11
  def inspect_field
10
12
  read_attribute(:inspect)
11
13
  end
@@ -25,7 +27,6 @@ class ErrorLog < ApplicationRecord
25
27
  inspect: safe_inspect,
26
28
  backtrace: cleaned.to_json
27
29
  )
28
- log.update_column(:slug, "error-log-#{log.id}")
29
30
 
30
31
  # Fan out to Sentry if the host app loaded sentry-ruby. Guard with
31
32
  # respond_to? so we don't blow up on old SDK versions. The DB ErrorLog
@@ -42,4 +43,12 @@ class ErrorLog < ApplicationRecord
42
43
 
43
44
  log
44
45
  end
46
+
47
+ private
48
+
49
+ # The id does not exist before the insert, so the first call returns a unique
50
+ # placeholder; Sluggable calls it again after the insert.
51
+ def name_slug
52
+ id ? "error-log-#{id}" : "error-log-new-#{SecureRandom.hex(8)}"
53
+ end
45
54
  end
@@ -1,4 +1,4 @@
1
- <%# locals: (label:, items:, per_view: 3, id: nil, slide_label: nil, data: {}, class_name: nil, style: :standard, caption: nil, start: nil, autoplay: nil, loop: false) %>
1
+ <%# locals: (label:, items:, per_view: 3, id: nil, slide_label: nil, data: {}, class_name: nil, style: :standard, caption: nil, start: nil, autoplay: nil, loop: false, visible: nil) %>
2
2
  <%# THE CAROUSEL: one sideways row of slides, the apps' standard for a set too
3
3
  long to grid. Render it as a layout, so each slide is your own block:
4
4
 
@@ -18,18 +18,25 @@
18
18
  slide is "2 of 6".
19
19
  data data attributes for the region, such as a test hook.
20
20
  class_name classes for the region, such as its width.
21
- style :standard (slides snap to the start) or :spotlight (the
21
+ style :standard (slides snap to the start), :spotlight (the
22
22
  centred slide is full size and colour, its neighbours smaller,
23
- faded and grey, and the arrows and keys move one slide).
23
+ faded and grey, and the arrows and keys move one slide), or
24
+ :cascade (a 3D stack: the open slide in front at full size,
25
+ the slides either side stepping back behind it, each step
26
+ smaller and dimmer; see THE CASCADE below).
27
+ visible a cascade's slides in view: 5 (the default: the open slide and
28
+ two each side) or 3. A region under 36rem wide shows 3 whatever
29
+ it asks. Only a cascade takes it.
24
30
  caption a callable that writes a line under a slide from its item. A
25
31
  standard row shows a small line under every slide; the
26
- spotlight shows it under the centred slide only.
32
+ spotlight and the cascade show it under the open slide only.
27
33
  start the slide the row opens on: an index from 0, or :middle (the
28
34
  slide at items.size / 2). Defaults to :middle for a spotlight
29
35
  that does not loop, so both halves of the row hold a slide at
30
36
  load, and to 0 for a standard row and for a looping spotlight
31
37
  (its wrap already shows the last slide left of the first, so it
32
- opens on the host's first item). An explicit start: wins. The browser sets it before the first paint where
38
+ opens on the host's first item). A cascade takes the spotlight's
39
+ defaults. An explicit start: wins. The browser sets it before the first paint where
33
40
  it has scroll-initial-target; elsewhere the controller sets it
34
41
  when it connects, with no glide and nothing announced. A link
35
42
  to an id inside the row (#fragment) wins over the start.
@@ -60,6 +67,18 @@
60
67
  transitions it. Under reduced motion nothing scales: the neighbours are only
61
68
  dimmed.
62
69
 
70
+ THE CASCADE (engine.css, carousel-cascade) does not scroll. Its slides share
71
+ one grid cell, and each wears data-cascade-step, its place from the open slide:
72
+ -2 to 2 in view, "before" and "after" past the window. The partial writes the
73
+ steps of the first paint and the controller rewrites them on a move, so CSS
74
+ lays the stack out (perspective, translateZ, a slight turn toward the centre)
75
+ and transitions it. An arrow, a dot, a key, a swipe or drag, autoplay, a click
76
+ on a side slide and keyboard focus in one bring that slide to the front; a
77
+ click on a side slide opens nothing. A looping cascade needs no clones: the
78
+ steps wrap round the set, so the window is never wider than the set. Under
79
+ reduced motion it lays flat, as a spotlight does, and swaps with no glide. If
80
+ its controller fails it falls back to a plain scrolling row.
81
+
63
82
  THE TRACK IS A NATIVE SCROLL-SNAP ROW (engine.css, carousel). A swipe, a
64
83
  trackpad, a wheel and a focused link move it with no script. The carousel
65
84
  controller (studio/controllers/carousel_controller) adds the edge fade, the
@@ -80,16 +99,25 @@
80
99
  per_view = per_view.to_i if per_view == per_view.floor
81
100
  items = items.to_a
82
101
  total = items.size
83
- spotlight = case style.to_s
84
- when "standard" then false
85
- when "spotlight" then true
86
- else raise ArgumentError, "studio/carousel style: #{style.inspect} is not :standard or :spotlight"
87
- end
102
+ style_name = style.to_s
103
+ unless %w[standard spotlight cascade].include?(style_name)
104
+ raise ArgumentError, "studio/carousel style: #{style.inspect} is not :standard, :spotlight or :cascade"
105
+ end
106
+ spotlight = style_name == "spotlight"
107
+ cascade = style_name == "cascade"
108
+ # The spotlight and the cascade both centre one slide: they share its defaults.
109
+ centred = spotlight || cascade
110
+ if cascade
111
+ visible = 5 if visible.nil?
112
+ raise ArgumentError, "studio/carousel visible: #{visible.inspect} is not 3 or 5" unless [3, 5].include?(visible)
113
+ elsif !visible.nil?
114
+ raise ArgumentError, "studio/carousel visible: only a style: :cascade row takes it"
115
+ end
88
116
  loop = loop ? true : false
89
117
  wraps = loop && total > 1
90
118
  # A looping spotlight has a left neighbour at its first slide (the clones), so it
91
119
  # opens there; one that does not loop opens mid-row so neither half is empty.
92
- start = spotlight && !wraps ? :middle : 0 if start.nil?
120
+ start = centred && !wraps ? :middle : 0 if start.nil?
93
121
  start_index = case start
94
122
  when :middle, "middle" then total / 2
95
123
  when Integer then start.clamp(0, [total - 1, 0].max)
@@ -100,20 +128,36 @@
100
128
  raise ArgumentError, "studio/carousel autoplay: #{autoplay.inspect} is not a number of seconds above 0 or nil"
101
129
  end
102
130
  autoplay = autoplay.to_i if autoplay && autoplay == autoplay.floor
103
- arrow_noun = spotlight ? "slide" : "slides"
131
+ arrow_noun = centred ? "slide" : "slides"
132
+ # A cascade slide's place from the open slide (studio/carousel's cascadeStep and
133
+ # cascadeSlot): signed, the short way round a looping set, the later side on a tie.
134
+ cascade_slot = lambda do |index|
135
+ step = index - start_index
136
+ if wraps
137
+ step %= total
138
+ step -= total if step > total / 2.0
139
+ end
140
+ step < -2 ? "before" : step > 2 ? "after" : step.to_s
141
+ end
104
142
  track_id = id.presence || "carousel-#{label.to_s.parameterize.presence || 'slides'}"
105
143
  %>
106
- <%= tag.section(class: ["carousel", ("carousel-spotlight" if spotlight), class_name].compact.join(" "),
144
+ <%= tag.section(class: ["carousel", ("carousel-spotlight" if spotlight), ("carousel-cascade" if cascade), class_name].compact.join(" "),
107
145
  role: "region",
108
146
  aria: { roledescription: "carousel", label: label },
109
147
  style: "--carousel-per-view: #{per_view}",
110
148
  data: { studio_controller: "carousel", studio_action: "keydown->carousel#key", carousel_start: (start_index.to_s if start_index.positive?),
111
- carousel_autoplay: (autoplay.to_s if autoplay), carousel_loop: ("" if loop) }.compact.merge(data)) do %>
149
+ carousel_autoplay: (autoplay.to_s if autoplay), carousel_loop: ("" if loop), carousel_visible: (visible.to_s if cascade) }.compact.merge(data)) do %>
112
150
  <%# The track takes focus for the keys, so it carries a name: a group, since a
113
151
  bare div may not be named. %>
114
152
  <div id="<%= track_id %>" class="carousel-track" tabindex="0" role="group"
115
153
  aria-label="<%= label %> slides" data-carousel-target="track">
116
154
  <% items.each_with_index do |item, index| %>
155
+ <% if cascade %>
156
+ <div class="carousel-slide" role="group" aria-roledescription="slide"
157
+ aria-label="<%= slide_label ? slide_label.call(item) : "#{index + 1} of #{total}" %>"
158
+ data-carousel-target="slide" data-cascade-step="<%= cascade_slot.call(index) %>"<%= ' data-current'.html_safe if index == start_index %>><div class="carousel-cascade-body"><%= yield(item) %></div><% if caption %><div class="carousel-caption"><%= caption.call(item) %></div><% end %></div>
159
+ <% next %>
160
+ <% end %>
117
161
  <div class="carousel-slide" role="group" aria-roledescription="slide"
118
162
  aria-label="<%= slide_label ? slide_label.call(item) : "#{index + 1} of #{total}" %>"
119
163
  data-carousel-target="slide"<%= ' data-carousel-start-slide'.html_safe if start_index.positive? && index == start_index %><%= ' data-current'.html_safe if spotlight && index == start_index %>><% if spotlight %><div class="carousel-spotlight-body"><%= yield(item) %></div><% else %><%= yield(item) %><% end %><% if caption %><div class="carousel-caption"><%= caption.call(item) %></div><% end %></div>
@@ -0,0 +1,50 @@
1
+ <%# locals: (integration:, connections:, return_to:, linked:) %>
2
+ <%# One integration's card: status, what keeps it off, Connect (a POST, with
3
+ the account field when the admin names the account at Connect), and its
4
+ connections with Disconnect. No token, and no part of one, is shown. %>
5
+ <% provider = integration.provider %>
6
+ <% status = integration.status %>
7
+ <section class="card p-4" data-studio-integration-card="<%= integration.key %>" data-status="<%= status %>">
8
+ <div class="flex flex-wrap items-center justify-between gap-2">
9
+ <h2 class="text-heading font-semibold">
10
+ <% if linked %><a href="<%= integration_path_for(integration) %>" class="hover:underline"><%= integration.label %></a><% else %><%= integration.label %><% end %>
11
+ </h2>
12
+ <span class="badge" data-studio-integration-status><%= integration.status_label %></span>
13
+ </div>
14
+ <p class="text-sm text-secondary mt-1"><%= integration.summary %></p>
15
+
16
+ <% integration.missing.each do |reason| %>
17
+ <p class="text-sm text-muted mt-2" data-studio-integration-missing><%= reason %></p>
18
+ <% end %>
19
+
20
+ <% if connections.any? %>
21
+ <ul class="mt-3 divide-y divide-subtle" data-studio-integration-connections>
22
+ <% connections.each do |connection| %>
23
+ <li class="py-2 flex flex-wrap items-center justify-between gap-2" data-studio-integration-connection="<%= connection.id %>">
24
+ <div>
25
+ <p class="text-sm text-heading font-medium"><%= connection.label %></p>
26
+ <p class="text-xs text-muted">
27
+ Connected <%= connection.connected_at.utc.strftime("%Y-%m-%d %H:%M UTC") %><% if connection.connected_by.present? %> by <%= connection.connected_by %><% end %><% if connection.metadata.to_h["environment"].present? %> · <%= connection.metadata["environment"] %><% end %>
28
+ </p>
29
+ </div>
30
+ <%= button_to "Disconnect", integration_disconnect_path(integration, connection), method: :delete,
31
+ params: { return_to: }, class: "btn btn-outline btn-sm",
32
+ form: { data: { turbo_confirm: "Disconnect #{connection.label}?" } } %>
33
+ </li>
34
+ <% end %>
35
+ </ul>
36
+ <% end %>
37
+
38
+ <%= form_with url: integration_connect_path(integration), method: :post, local: true,
39
+ class: "mt-3 flex flex-wrap items-end gap-2", data: { studio_integration_connect: integration.key, turbo: false } do |form| %>
40
+ <%= hidden_field_tag :return_to, return_to %>
41
+ <% if provider.account_source == :connect %>
42
+ <label class="text-sm text-secondary">
43
+ <span class="block mb-1"><%= integration.definition.account_label %></span>
44
+ <%= text_field_tag provider.account_param, nil, class: "input-field py-2", required: true, autocomplete: "off",
45
+ placeholder: integration.definition.account_hint %>
46
+ </label>
47
+ <% end %>
48
+ <%= form.submit(connections.any? ? "Connect another" : "Connect", class: "btn btn-primary btn-sm") %>
49
+ <% end %>
50
+ </section>