studio-engine 0.141.3 → 0.142.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -1
  3. data/README.md +103 -1
  4. data/app/assets/tailwind/studio_engine/engine.css +90 -18
  5. data/app/components/previews/studio/button_component_preview/disabled.html.erb +12 -0
  6. data/app/components/previews/studio/button_component_preview/icon_and_link.html.erb +7 -0
  7. data/app/components/previews/studio/button_component_preview/loading.html.erb +12 -0
  8. data/app/components/previews/studio/button_component_preview/sizes.html.erb +9 -0
  9. data/app/components/previews/studio/button_component_preview/tones.html.erb +5 -0
  10. data/app/components/previews/studio/button_component_preview.rb +34 -0
  11. data/app/components/previews/studio/empty_state_component_preview/with_icon_and_action.html.erb +5 -0
  12. data/app/components/previews/studio/empty_state_component_preview.rb +29 -0
  13. data/app/components/previews/studio/table_component_preview/rows.html.erb +14 -0
  14. data/app/components/previews/studio/table_component_preview.rb +33 -0
  15. data/app/components/studio/button_component.html.erb +1 -0
  16. data/app/components/studio/button_component.rb +151 -0
  17. data/app/components/studio/empty_state_component.html.erb +12 -0
  18. data/app/components/studio/empty_state_component.rb +64 -0
  19. data/app/components/studio/table_component.html.erb +32 -0
  20. data/app/components/studio/table_component.rb +152 -0
  21. data/app/controllers/studio/modals_controller.rb +48 -0
  22. data/app/javascript/studio/carousel.js +52 -0
  23. data/app/javascript/studio/controllers/carousel_controller.js +7 -13
  24. data/app/javascript/studio/in_page_links.js +85 -11
  25. data/app/javascript/studio/modal_host.js +219 -0
  26. data/app/models/error_log.rb +5 -5
  27. data/app/views/components/_empty_state.html.erb +2 -6
  28. data/app/views/studio/modals/_host.html.erb +34 -2
  29. data/app/views/studio/modals/remote.html.erb +5 -0
  30. data/app/views/style/_components.html.erb +5 -2
  31. data/app/views/style/_glass.html.erb +9 -1
  32. data/app/views/style/_primitives.html.erb +106 -0
  33. data/config/studio_tricks.yml +6 -2
  34. data/db/migrate/20261109180000_add_engine_not_null_checks.rb +148 -0
  35. data/lib/studio/modals.rb +101 -0
  36. data/lib/studio/version.rb +1 -1
  37. data/lib/studio.rb +11 -0
  38. metadata +22 -1
@@ -211,19 +211,23 @@ glass:
211
211
  name: Glass
212
212
  group: Glass
213
213
  token: "config.surface_style = :glass"
214
- note: "Frosts the cards and lights the controls from above; opt-in per app."
214
+ note: "Frosts the cards and lights the controls; the standard for a new app."
215
215
  resolve: [config:surface_style, css:surface-glass-blur]
216
216
  variants:
217
217
  lit:
218
218
  token: ".btn, .badge, .input-field under surface_style = :glass"
219
219
  note: "Buttons, badges and inputs lit from above, in every state."
220
220
  resolve: [config:surface_style]
221
+ tile:
222
+ token: ".lit-tile, .lit-tile-soft under surface_style = :glass"
223
+ note: "An app's own call-to-action tile, lit like a button."
224
+ resolve: [css:lit-tile, css:lit-tile-soft]
221
225
 
222
226
  glass_sidebar:
223
227
  name: Glass Sidebar
224
228
  group: Glass
225
229
  token: "config.sidebar_style = :glass"
226
- note: "A frosted link sidebar; opt-in per app."
230
+ note: "A frosted link sidebar in the app's colours; the standard for a new app."
227
231
  resolve: [config:sidebar_style]
228
232
 
229
233
  icon:
@@ -0,0 +1,148 @@
1
+ # The second half of the engine's slug standard and of the role standard: the
2
+ # database refuses a NULL `slug` on error_logs, theme_settings,
3
+ # studio_geo_settings and studio_site_identities, and a NULL `role` on the
4
+ # host's users (database audit 2026-11-07; the steps
5
+ # RequireSlugsOnEngineTables and AddUsersRoleKnownCheck deferred to a later
6
+ # release, engine-not-null-next-release).
7
+ #
8
+ # THE FORM: a validated CHECK named `<table>_<column>_present`, holding
9
+ # `<column> IS NOT NULL`, not a column-level NOT NULL.
10
+ #
11
+ # * It is the same rule to every write: Postgres refuses the NULL insert or
12
+ # update either way.
13
+ # * It is the one form the release's schema-dump guard
14
+ # (Release::EngineMigrationInstall.schema_dump_safe?) accepts. The sweep
15
+ # migrates each host's throwaway database and diffs db/schema.rb; the guard
16
+ # passes a diff that only ADDS lines. `change_column_null` rewrites the
17
+ # existing `t.string "slug"` line to `t.string "slug", null: false`, a
18
+ # removed line, and the guard refuses it by design. A CHECK adds one
19
+ # `t.check_constraint` line and removes nothing.
20
+ # * Its dump is stable. Postgres deparses `slug IS NOT NULL` to
21
+ # `(slug IS NOT NULL)` and deparses that to itself again, so a host's dump
22
+ # after this migrate equals its dump after a schema load, byte for byte
23
+ # (unlike an `IN (...)` list, see AddUsersRoleKnownCheck#expression).
24
+ # * On Postgres 12 and later, a validated `CHECK (x IS NOT NULL)` lets a
25
+ # later `SET NOT NULL` skip its table scan, if that form is ever wanted.
26
+ #
27
+ # ROLLBACK SAFETY. A column that is NOT NULL breaks every insert that leaves it
28
+ # NULL, and Heroku runs this migration while the previous release's dynos still
29
+ # serve, and a `heroku rollback` keeps the constraint. So this migration ships
30
+ # only once every host already runs code that never writes a NULL here:
31
+ #
32
+ # * error_logs: ErrorLog includes Sluggable from 0.138.0 (first shipped in
33
+ # 0.141.0), which writes a placeholder slug before the insert. Up to 0.137.0,
34
+ # ErrorLog.capture! inserted a NULL slug and filled it after.
35
+ # * theme_settings, studio_geo_settings, studio_site_identities: Sluggable
36
+ # since 0.129.0.
37
+ # * users.role: the column's default is "viewer" on every host, and no engine
38
+ # or host code writes an explicit NULL role.
39
+ #
40
+ # The error_logs line is the floor: a host must have run 0.141.0 or later in
41
+ # production for one release before it installs this migration. A host that
42
+ # jumps from 0.137.0 or older straight to this version in one release breaks
43
+ # its own error writes while the old dynos drain, and again on a rollback.
44
+ #
45
+ # Each step, per table, and a table or column the host does not have is skipped:
46
+ #
47
+ # 1. A column that is already NOT NULL (the hub's and Turf's users.role) needs
48
+ # no CHECK: skipped.
49
+ # 2. A CHECK of this name already there: skipped, so a second run is a no-op.
50
+ # 3. Backfill. A NULL or blank slug gets the one its model would write (the
51
+ # same rule as RequireSlugsOnEngineTables, copied, since a migration must
52
+ # not depend on another migration's class); a NULL role becomes "viewer".
53
+ # 4. Add the CHECK, validated.
54
+ #
55
+ # Production on 2026-11-08 held no NULL slug and no NULL role. On Postgres each
56
+ # ALTER waits at most LOCK_TIMEOUT for its lock; a migrate that cannot get it
57
+ # fails whole, changes nothing, and runs again on the next release.
58
+ class AddEngineNotNullChecks < ActiveRecord::Migration[7.2]
59
+ LOCK_TIMEOUT = "10s"
60
+
61
+ # table => the slug prefix its model's name_slug writes before the app name.
62
+ # error_logs has no app name: its slug is the id.
63
+ NAMED = {
64
+ "theme_settings" => "theme",
65
+ "studio_geo_settings" => "geo",
66
+ "studio_site_identities" => "site-identity"
67
+ }.freeze
68
+ SLUG_TABLES = ([ "error_logs" ] + NAMED.keys).freeze
69
+
70
+ # [table, column] pairs, in the order `up` runs them.
71
+ COLUMNS = (SLUG_TABLES.map { |table| [ table, "slug" ] } + [ %w[users role] ]).freeze
72
+
73
+ def self.constraint_name(table, column) = "#{table}_#{column}_present"
74
+
75
+ def up
76
+ set_lock_timeout
77
+ COLUMNS.each do |table, column|
78
+ next unless nullable_column?(table, column)
79
+
80
+ name = self.class.constraint_name(table, column)
81
+ next if check_constraint_exists?(table, name: name)
82
+
83
+ column == "role" ? backfill_role : backfill_slug(table)
84
+ add_check_constraint table, "#{column} IS NOT NULL", name: name
85
+ end
86
+ end
87
+
88
+ # Removes only the CHECKs `up` adds. Backfilled values stay: they are valid
89
+ # either way. Run it before a rollback to a release older than 0.141.0 (see
90
+ # the changelog): that code inserts a NULL error_logs slug.
91
+ def down
92
+ COLUMNS.each do |table, column|
93
+ next unless table_exists?(table)
94
+
95
+ remove_check_constraint table, name: self.class.constraint_name(table, column), if_exists: true
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ def postgres? = connection.adapter_name.downcase.include?("postgres")
102
+
103
+ def set_lock_timeout
104
+ return unless postgres?
105
+
106
+ execute "SET LOCAL lock_timeout = '#{LOCK_TIMEOUT}'"
107
+ end
108
+
109
+ def nullable_column?(table, column)
110
+ return false unless table_exists?(table)
111
+
112
+ found = columns(table).find { |c| c.name == column }
113
+ found.present? && found.null
114
+ end
115
+
116
+ def quoted(table) = connection.quote_table_name(table)
117
+
118
+ def backfill_role
119
+ execute "UPDATE users SET role = 'viewer' WHERE role IS NULL"
120
+ end
121
+
122
+ def backfill_slug(table)
123
+ rows = connection.select_rows(<<~SQL.squish)
124
+ SELECT id#{', app_name' if NAMED.key?(table)} FROM #{quoted(table)}
125
+ WHERE slug IS NULL OR slug = '' ORDER BY id
126
+ SQL
127
+ rows.each do |id, app_name|
128
+ derived = derived_slug(table, id, app_name)
129
+ derived = "#{derived}-#{id}" if taken?(table, derived, id)
130
+ connection.update(<<~SQL.squish, "#{self.class.name} backfill")
131
+ UPDATE #{quoted(table)} SET slug = #{connection.quote(derived)} WHERE id = #{Integer(id)}
132
+ SQL
133
+ end
134
+ end
135
+
136
+ def derived_slug(table, id, app_name)
137
+ return "error-log-#{id}" unless NAMED.key?(table)
138
+
139
+ name = ActiveSupport::Inflector.parameterize(app_name.to_s)
140
+ name.empty? ? "#{NAMED.fetch(table)}-#{id}" : "#{NAMED.fetch(table)}-#{name}"
141
+ end
142
+
143
+ def taken?(table, slug, id)
144
+ connection.select_value(<<~SQL.squish).present?
145
+ SELECT 1 FROM #{quoted(table)} WHERE slug = #{connection.quote(slug)} AND id <> #{Integer(id)} LIMIT 1
146
+ SQL
147
+ end
148
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # The on-demand modal registry: modals the shared host (studio/modals/_host)
5
+ # fetches the first time they open, instead of the layout rendering every
6
+ # template into every page.
7
+ #
8
+ # # config/initializers/studio.rb
9
+ # Studio.modals.register :contest_rules, partial: "modals/contest_rules"
10
+ # Studio.modals.register :wallet_setup, partial: "modals/wallet_setup",
11
+ # auth: :user, locals: :wallet_setup_modal_locals
12
+ # Studio.modals.register :auth, partial: "modals/auth", prefetch: true
13
+ #
14
+ # A registered id is served by GET /_studio/modals/:id
15
+ # (Studio::ModalsController) and opened with the same
16
+ # $store.modals.open(id, props) call as before. Render it ONLY through the
17
+ # registry: an id that the layout still renders AND the registry names would
18
+ # mount twice. Every id the registry does not name behaves as it always did.
19
+ #
20
+ # Options:
21
+ # partial: the content partial, rendered exactly as the layout rendered it
22
+ # (one root element; see the README's "Modal host" rules).
23
+ # auth: :any (default) or :user. A :user modal answers 401 to a
24
+ # 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
+ # locals: the partial's locals. A Hash, a Symbol naming a helper the view
28
+ # answers (Turf's existing *_modal_locals helpers), or a callable
29
+ # taking the view. Resolved per request, inside the view.
30
+ module Modals
31
+ AUTH_LEVELS = %i[any user].freeze
32
+
33
+ # The id lands inside a single-quoted JavaScript string in the host's x-if,
34
+ # and in a URL path segment, so it is held to a shape that needs no escaping
35
+ # in either place.
36
+ ID_FORMAT = /\A[a-z0-9][a-z0-9_-]*\z/
37
+
38
+ Entry = Struct.new(:id, :partial, :auth, :prefetch, :locals, keyword_init: true) do
39
+ def user_only? = auth == :user
40
+
41
+ # The locals for one render, resolved in that render's view.
42
+ def locals_for(view)
43
+ resolved =
44
+ case locals
45
+ when nil then {}
46
+ when Hash then locals
47
+ when Symbol, String then view.public_send(locals)
48
+ else locals.arity.zero? ? view.instance_exec(&locals) : locals.call(view)
49
+ end
50
+ raise ArgumentError, "Studio.modals #{id}: locals must resolve to a Hash, got #{resolved.class}" unless resolved.is_a?(Hash)
51
+
52
+ resolved
53
+ end
54
+ end
55
+
56
+ class Registry
57
+ def initialize
58
+ @entries = {}
59
+ end
60
+
61
+ def register(id, partial:, auth: :any, prefetch: false, locals: nil)
62
+ key = id.to_s
63
+ raise ArgumentError, "Studio.modals: id #{id.inspect} must match #{ID_FORMAT.inspect}" unless key.match?(ID_FORMAT)
64
+ raise ArgumentError, "Studio.modals #{key}: partial is required" if partial.to_s.strip.empty?
65
+ raise ArgumentError, "Studio.modals #{key}: auth must be one of #{AUTH_LEVELS.inspect}" unless AUTH_LEVELS.include?(auth)
66
+ unless locals.nil? || locals.is_a?(Hash) || locals.is_a?(Symbol) || locals.is_a?(String) || locals.respond_to?(:call)
67
+ raise ArgumentError, "Studio.modals #{key}: locals must be a Hash, a helper name or a callable"
68
+ end
69
+
70
+ @entries[key] = Entry.new(id: key, partial: partial.to_s, auth: auth, prefetch: prefetch == true, locals: locals)
71
+ end
72
+
73
+ def [](id) = @entries[id.to_s]
74
+ def registered?(id) = @entries.key?(id.to_s)
75
+ def ids = @entries.keys
76
+ def entries = @entries.values
77
+ def any? = @entries.any?
78
+ def empty? = @entries.empty?
79
+
80
+ # Removes one id, or every id. For tests and for a host that registers
81
+ # conditionally on reload.
82
+ def unregister(id) = @entries.delete(id.to_s)
83
+ def clear! = @entries.clear
84
+
85
+ # What the host template advertises to studio/modal_host. Every id, so a
86
+ # signed-out open of a :user modal still reaches the endpoint and shows
87
+ # the error card; prefetch only what this viewer may load.
88
+ def client_config(url_template:, logged_in:)
89
+ {
90
+ url: url_template,
91
+ ids: ids,
92
+ prefetch: entries.select { |e| e.prefetch && (!e.user_only? || logged_in) }.map(&:id)
93
+ }
94
+ end
95
+ end
96
+ end
97
+
98
+ def self.modals
99
+ @modals ||= Modals::Registry.new
100
+ end
101
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.141.3"
2
+ VERSION = "0.142.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -33,6 +33,7 @@ require "studio/js_literal"
33
33
  require "studio/js_identifier"
34
34
  require "studio/hold_button"
35
35
  require "studio/partial_block"
36
+ require "studio/modals"
36
37
  require "studio/sidebar_sections"
37
38
  require "studio/icons"
38
39
  require "studio/site_footer"
@@ -306,6 +307,7 @@ module Studio
306
307
  # today. :glass floats an inset, translucent panel over the page, with line icons
307
308
  # (a link's icon:), a marked current page and no separate blurred edge. It is opt-in,
308
309
  # so an app moves only when it asks. engine.css, THE GLASS SIDEBAR, holds the look.
310
+ # :glass is the standard for a NEW app (docs/NEW_APP_SETUP.md); the default stays put.
309
311
  mattr_reader :sidebar_style, default: :default
310
312
 
311
313
  def self.sidebar_style=(value)
@@ -316,6 +318,7 @@ module Studio
316
318
  # How cards look. :glass makes them translucent over a soft ambient page, once the host
317
319
  # layout says so on its root: <html data-surface-style="<%= Studio.surface_style %>">.
318
320
  # :default is what every app has today. engine.css, THE GLASS SURFACES, holds the look.
321
+ # :glass is the standard for a NEW app (docs/NEW_APP_SETUP.md); the default stays put.
319
322
  SURFACE_STYLES = %i[default glass].freeze
320
323
  mattr_reader :surface_style, default: :default
321
324
 
@@ -2192,6 +2195,14 @@ module Studio
2192
2195
  get "_studio/health_report", to: "studio/health_reports#show", as: :studio_health_report,
2193
2196
  defaults: { format: :json }
2194
2197
 
2198
+ # The on-demand modal fragments (Studio::ModalsController, Studio.modals).
2199
+ # Drawn for every app: the controller is the gate, answering 404 for any
2200
+ # id the registry does not name, so an app that registers nothing serves
2201
+ # nothing. HTML only; studio/modal_host is its one caller.
2202
+ get "_studio/modals/:id", to: "studio/modals#show", as: :studio_modal,
2203
+ constraints: { id: /[a-z0-9][a-z0-9_-]*/ },
2204
+ defaults: { format: :html }
2205
+
2195
2206
  # Developer-desk tools. Drawn outside production, and each controller
2196
2207
  # re-checks Studio.local_tool_enabled? per request (loopback only) — the
2197
2208
  # route being absent is the outer gate, not the only one.
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.141.3
4
+ version: 0.142.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -439,8 +439,24 @@ files:
439
439
  - app/components/previews/studio/badge_component_preview.rb
440
440
  - app/components/previews/studio/badge_component_preview/schemes.html.erb
441
441
  - app/components/previews/studio/badge_component_preview/tones.html.erb
442
+ - app/components/previews/studio/button_component_preview.rb
443
+ - app/components/previews/studio/button_component_preview/disabled.html.erb
444
+ - app/components/previews/studio/button_component_preview/icon_and_link.html.erb
445
+ - app/components/previews/studio/button_component_preview/loading.html.erb
446
+ - app/components/previews/studio/button_component_preview/sizes.html.erb
447
+ - app/components/previews/studio/button_component_preview/tones.html.erb
448
+ - app/components/previews/studio/empty_state_component_preview.rb
449
+ - app/components/previews/studio/empty_state_component_preview/with_icon_and_action.html.erb
450
+ - app/components/previews/studio/table_component_preview.rb
451
+ - app/components/previews/studio/table_component_preview/rows.html.erb
442
452
  - app/components/studio/badge_component.html.erb
443
453
  - app/components/studio/badge_component.rb
454
+ - app/components/studio/button_component.html.erb
455
+ - app/components/studio/button_component.rb
456
+ - app/components/studio/empty_state_component.html.erb
457
+ - app/components/studio/empty_state_component.rb
458
+ - app/components/studio/table_component.html.erb
459
+ - app/components/studio/table_component.rb
444
460
  - app/controllers/concerns/solana/session_auth.rb
445
461
  - app/controllers/concerns/studio/admin_models.rb
446
462
  - app/controllers/concerns/studio/board/reorderable.rb
@@ -474,6 +490,7 @@ files:
474
490
  - app/controllers/studio/local_emails_controller.rb
475
491
  - app/controllers/studio/local_reviews_controller.rb
476
492
  - app/controllers/studio/marketing_kit_controller.rb
493
+ - app/controllers/studio/modals_controller.rb
477
494
  - app/controllers/studio/models_controller.rb
478
495
  - app/controllers/studio/not_found_controller.rb
479
496
  - app/controllers/studio/onboarding_controller.rb
@@ -779,6 +796,7 @@ files:
779
796
  - app/views/studio/modals/blocks/_success_card.html.erb
780
797
  - app/views/studio/modals/blocks/_wallet_brand_sprite.html.erb
781
798
  - app/views/studio/modals/onboarding/_first_name.html.erb
799
+ - app/views/studio/modals/remote.html.erb
782
800
  - app/views/studio/modals/shared/_age_attestation.html.erb
783
801
  - app/views/studio/modals/shared/_email_field.html.erb
784
802
  - app/views/studio/modals/templates/_action.html.erb
@@ -838,6 +856,7 @@ files:
838
856
  - app/views/style/_modal_specimen.html.erb
839
857
  - app/views/style/_modals.html.erb
840
858
  - app/views/style/_pages.html.erb
859
+ - app/views/style/_primitives.html.erb
841
860
  - app/views/style/_scrims.html.erb
842
861
  - app/views/style/_site_footer.html.erb
843
862
  - app/views/style/_specimen.html.erb
@@ -892,6 +911,7 @@ files:
892
911
  - db/migrate/20261108160000_require_slugs_on_engine_tables.rb
893
912
  - db/migrate/20261108160001_add_engine_state_checks.rb
894
913
  - db/migrate/20261108170000_add_users_role_known_check.rb
914
+ - db/migrate/20261109180000_add_engine_not_null_checks.rb
895
915
  - docs/BOOKING.md
896
916
  - docs/SITE_FOOTER.md
897
917
  - lib/active_storage/service/studio_trash_s3_service.rb
@@ -965,6 +985,7 @@ files:
965
985
  - lib/studio/mail_sender.rb
966
986
  - lib/studio/mail_transport.rb
967
987
  - lib/studio/marketing_kit.rb
988
+ - lib/studio/modals.rb
968
989
  - lib/studio/name_parts.rb
969
990
  - lib/studio/navbar_account_sidebar.rb
970
991
  - lib/studio/navbar_brand.rb