studio-engine 0.91.2 → 0.92.1

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: 270fe9f569b46f626593afa13ce7cf925f23e783541f76e06d6a465d06837278
4
- data.tar.gz: 40f8eefcec2e4eee79baa652da57aca97a6b49b794fab4fb3ca166dd3f27333d
3
+ metadata.gz: 33eb1f55bd158e9d6c588739db65fbef1a5ba8b707b1e0c0469b539d3e6643fa
4
+ data.tar.gz: f3c5845a77976e33edc87e04da3de0e08fd890203f901352a3b419ee3f58f489
5
5
  SHA512:
6
- metadata.gz: ae76749cc7fd004b7a93b2108d0af84e21645b1801f50be212d0d5346d503f8d3355fea7c3350ab74a059edbcfc4d60163b245ae34c7f4c68a249e1115b5588f
7
- data.tar.gz: 683b4be2cb7b68b01c167db0e3973aefe9b0dd133b8ddb200f5d76f387d19dc1bff6378204fc0e34c4716e1b08bd9e0d08b4f572e40d3b2c15d007e97a6ba48e
6
+ metadata.gz: ec17e35ab084a92080f6bc9fa9565cd5ca6a2f71c8f9f75311ef32f1955c140d9391ed34fa78adcffa8fb84892e043074cf5d7cd89b9f09418edf4af72007b9c
7
+ data.tar.gz: 411eed22d765f4fa318805debbd117353b38f6d518d69b411673bca76d73375812d9f0baa36f69fa1ab25f55976302fe8d37f9ffe5231a4fbd9bfc4002e1b5e8
data/CHANGELOG.md CHANGED
@@ -4,6 +4,67 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.92.1 — 2026-10-07
8
+
9
+ ### Added
10
+
11
+ - **The engine owns `error_logs`, `theme_settings` and `image_caches`.** Three
12
+ migrations (`ensure_error_logs_table`, `ensure_theme_settings_table`,
13
+ `ensure_image_caches_table`) create each table when it is absent and, on an app
14
+ that already has it, add only the engine columns it lacks, as nullable columns;
15
+ nothing is altered or dropped and no index is added. Adopt with
16
+ `bin/rails studio_engine:install:migrations` and `bin/rails db:migrate`; the
17
+ release's lock bump does both. Hosts no longer copy these tables from
18
+ `docs/NEW_APP_SETUP.md`.
19
+ - **A boot check names a missing engine column** (`Studio::HostSchema`). It
20
+ raises `Studio::HostSchemaError` in development and test, and logs (and reports
21
+ to Sentry) in production; `Studio.host_schema_check = :raise | :log | false`
22
+ overrides. Extra host columns are never reported, and rake tasks skip it.
23
+
24
+ ### Fixed
25
+
26
+ - **A theme save no longer raises on apps without `theme_settings.slug`**
27
+ (Cyvasse, McRitchie Industries and Moms App): `ensure_theme_settings_table`
28
+ adds the column `ThemeSetting` writes on every save.
29
+
30
+ ## 0.92.0 — 2026-10-06
31
+
32
+ ### Added
33
+
34
+ - **ViewComponent comes with the engine.** It is a runtime dependency, so an
35
+ app renders the engine's components with no Gemfile line of its own. Lookbook
36
+ is not: an app that wants the component gallery adds
37
+ `gem "lookbook", group: [:development, :test]` after studio-engine (ungrouped,
38
+ with `Studio.lookbook_in_production`, for a live gallery). Without it the app
39
+ loads no Lookbook code, which keeps tens of megabytes out of every production
40
+ process. The front-end standard (`docs/FRONT_END_STANDARD.md`) now names a
41
+ primitive's public surface (its inputs, its `data-*` hooks and its named CSS
42
+ classes) and what a change to it takes: a Breaking line here and a
43
+ consumer-CI run against every consumer before publish.
44
+ - **`Studio::BadgeComponent`, the first component.** It takes `text:`,
45
+ `data_board_count:`, and either `tone:` (one of the five status roles,
46
+ `success`, `warning`, `danger`, `primary`, `muted`, with the same chip classes
47
+ as the hub's `status_tone`) or `scheme:` (the palette the badge partial took).
48
+ The `components/badge` partial now renders the component and its output is
49
+ unchanged, so no caller changes this release. `engine.css` adds the engine's
50
+ `app/components` to every host's Tailwind scan.
51
+ - **The component gallery at `/admin/style/components`.** Lookbook over the
52
+ engine's previews, with a Components section on `/admin/style` linking each
53
+ state. A signed-in admin gets it; a visitor or non-admin gets 404. It shows
54
+ the rendered output only (no Source or Params panel). It is drawn only where
55
+ the app's bundle loads lookbook: in development and test, and in production
56
+ only with `Studio.lookbook_in_production = true`. No app draws ViewComponent's
57
+ preview routes in production. ViewComponent's own preview pages move to
58
+ `/admin/style/previews`, behind the same wall.
59
+ - **The engine's own importmap pins.** The engine's `config/importmap.rb` pins
60
+ each module under `app/javascript/studio` as `studio/<name>` (not preloaded),
61
+ and the `studio.importmap` initializer draws it into every host that has
62
+ importmap-rails, before the host's own map, so a host pin of the same name
63
+ wins. No host edit. The first module is `studio/local_path`, the browser twin
64
+ of `Studio::LocalPath.local?`.
65
+ - **Consumer CI runs Cyvasse's suite** against each engine change, beside the
66
+ hub, Turf Monster and McRitchie Industries.
67
+
7
68
  ## 0.91.2 — 2026-10-06
8
69
 
9
70
  ### Security
data/Gemfile CHANGED
@@ -102,3 +102,11 @@ end
102
102
  group :development, :test do
103
103
  gem "solana-studio", ">= 0.5.3"
104
104
  end
105
+
106
+ # importmap-rails is the dummy host's importmap, so the engine's pin contract
107
+ # (config/importmap.rb, drawn in by the studio.importmap initializer) is tested
108
+ # against the real gem every consumer runs. Not a runtime dependency: the
109
+ # initializer skips a host without importmap-rails (a footer-only consumer).
110
+ group :development, :test do
111
+ gem "importmap-rails", ">= 2.0"
112
+ end
@@ -28,6 +28,14 @@
28
28
  their local copies for this import.
29
29
  */
30
30
 
31
+ /* -- Components: in every host's class scan --------------------------------
32
+ The engine's ViewComponent classes (app/components) carry their colour
33
+ classes in Ruby, and a host's Tailwind config scans only the engine's
34
+ app/views. This adds app/components to the scan of every host that imports
35
+ this file, with no host edit. The path is relative to this file.
36
+ test/integration/component_tailwind_source_test.rb proves it compiles. */
37
+ @source "../../../components";
38
+
31
39
  /* -- Layer scale ------------------------------------------------------------
32
40
  The ONE stacking order every Studio app shares. Read the ORDER, not the
33
41
  number: the tier is the contract, the integer is an implementation detail.
@@ -0,0 +1,5 @@
1
+ <div class="flex flex-wrap gap-2">
2
+ <% schemes.each do |scheme| %>
3
+ <%= render Studio::BadgeComponent.new(text: scheme, scheme: scheme) %>
4
+ <% end %>
5
+ </div>
@@ -0,0 +1,5 @@
1
+ <div class="flex flex-wrap gap-2">
2
+ <% tones.each do |tone| %>
3
+ <%= render Studio::BadgeComponent.new(text: tone.to_s, tone: tone) %>
4
+ <% end %>
5
+ </div>
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # Every state Studio::BadgeComponent can show: the five status tones, every
5
+ # legacy scheme including the stage ladder, the neutral default, and the board
6
+ # count hook. No @param tags: the gallery takes no input.
7
+ class BadgeComponentPreview < ViewComponent::Preview
8
+ # The five status roles, as a hub view passes them from status_tone_role.
9
+ def tones
10
+ render_with_template(locals: { tones: Studio::BadgeComponent::TONES.keys })
11
+ end
12
+
13
+ # The palette the components/badge partial takes, stage ladder included.
14
+ def schemes
15
+ render_with_template(locals: { schemes: Studio::BadgeComponent::SCHEMES.keys })
16
+ end
17
+
18
+ # No tone and no scheme: the neutral scheme.
19
+ def default
20
+ render Studio::BadgeComponent.new(text: "neutral")
21
+ end
22
+
23
+ # A board column's count, which studio/board updates through
24
+ # data-board-count.
25
+ def board_count
26
+ render Studio::BadgeComponent.new(text: "12", scheme: "stage-fresh", data_board_count: "fresh")
27
+ end
28
+ end
29
+ end
@@ -0,0 +1 @@
1
+ <%= content_tag :span, text, class: "badge #{colour_classes}", data: (data_board_count ? { board_count: data_board_count } : nil) %>
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # A short label in a pill: a count, a repo name, a stage, a status.
5
+ #
6
+ # render Studio::BadgeComponent.new(text: "3")
7
+ # render Studio::BadgeComponent.new(text: "passed", tone: :success)
8
+ # render Studio::BadgeComponent.new(text: "news", scheme: "stage-fresh")
9
+ #
10
+ # Its public surface, which a release changes only with a Breaking line in the
11
+ # changelog (docs/FRONT_END_STANDARD.md, "A primitive's public surface"):
12
+ # inputs text:, tone: or scheme:, data_board_count:
13
+ # classes .badge plus the tone's or scheme's colour classes
14
+ # data-* data-board-count, the studio/board count target (its updateCounts
15
+ # sets the badge's text)
16
+ #
17
+ # tone: is one of the five status roles every app shares (TONES). Its classes
18
+ # are the hub's status_tone chip, minus the `border` width .badge already sets,
19
+ # so a hub view passes `tone: status_tone_role(status)` and gets the colour the
20
+ # rest of the page uses for that status. Tokens only, so it reads in both themes.
21
+ #
22
+ # scheme: is the palette the components/badge partial took (SCHEMES), including
23
+ # the stage-* pipeline ladder, and renders exactly what that partial rendered.
24
+ # An unknown scheme renders FALLBACK, as the partial did.
25
+ #
26
+ # Pass tone: or scheme:, not both. Neither renders the neutral scheme.
27
+ #
28
+ # Every class string is written out whole: Tailwind finds utilities by scanning
29
+ # source text, and engine.css adds app/components to every host's scan.
30
+ class BadgeComponent < ViewComponent::Base
31
+ strip_trailing_whitespace
32
+
33
+ TONES = {
34
+ success: "bg-success/10 text-success-ink border-success/40",
35
+ warning: "bg-warning/10 text-warning-ink border-warning/40",
36
+ danger: "bg-danger/10 text-danger-ink border-danger/40",
37
+ primary: "bg-primary/10 text-heading border-primary/40",
38
+ muted: "bg-surface-alt text-muted border-subtle"
39
+ }.freeze
40
+
41
+ SCHEMES = {
42
+ "success" => "bg-mint/10 text-mint border-mint/30",
43
+ "danger" => "bg-red-600/10 text-red-400 border-red-600/30",
44
+ "warning" => "bg-yellow-500/10 text-yellow-400 border-yellow-500/30",
45
+ "info" => "bg-blue-500/10 text-blue-500 border-blue-500/30",
46
+ "violet" => "bg-violet/10 text-violet border-violet/30",
47
+ "primary" => "bg-primary/10 text-primary border-primary/30",
48
+ "orange" => "bg-orange-500/10 text-orange-500 border-orange-500/30",
49
+ "emerald" => "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
50
+ "gray" => "bg-gray-500/10 text-gray-400 border-gray-500/30",
51
+ # The pipeline ladder News and Content share, first stage to archived.
52
+ "stage-fresh" => "bg-blue-500/10 text-blue-500 border-blue-500/30",
53
+ "stage-shaping" => "bg-yellow-500/10 text-yellow-400 border-yellow-500/30",
54
+ "stage-structured" => "bg-mint/10 text-mint border-mint/30",
55
+ "stage-refined" => "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
56
+ "stage-cohered" => "bg-violet/10 text-violet border-violet/30",
57
+ "stage-shipped" => "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
58
+ "stage-closed" => "bg-gray-500/10 text-gray-400 border-gray-500/30",
59
+ "neutral" => "bg-surface-alt text-secondary border-subtle"
60
+ }.freeze
61
+
62
+ FALLBACK = "bg-surface-alt text-body border-subtle"
63
+
64
+ attr_reader :text, :data_board_count
65
+
66
+ def initialize(text:, tone: nil, scheme: nil, data_board_count: nil)
67
+ super()
68
+ raise ArgumentError, "Studio::BadgeComponent takes tone: or scheme:, not both" if tone && scheme
69
+ if tone && !TONES.key?(tone.to_s.to_sym)
70
+ raise ArgumentError, "unknown tone #{tone.inspect}; expected one of #{TONES.keys.join(', ')}"
71
+ end
72
+
73
+ @text = text
74
+ @tone = tone&.to_s&.to_sym
75
+ @scheme = scheme
76
+ @data_board_count = data_board_count
77
+ end
78
+
79
+ def colour_classes
80
+ return TONES.fetch(@tone) if @tone
81
+
82
+ SCHEMES.fetch((@scheme || "neutral").to_s, FALLBACK)
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # ViewComponent's preview controller, behind the admin wall. ViewComponent
5
+ # draws its own preview pages at Studio::ComponentGallery::PREVIEWS_ROUTE
6
+ # wherever previews are on (development and test), and Lookbook renders every
7
+ # preview in the gallery through this same controller. Anyone but a signed-in
8
+ # admin gets 404, so neither path shows a component to a visitor even if a
9
+ # route is drawn that the gallery's router constraint does not cover.
10
+ #
11
+ # It inherits ViewComponent's controller (Rails::ApplicationController), not
12
+ # the host's ApplicationController, so the host's own before_actions do not
13
+ # run inside a preview render.
14
+ class ComponentPreviewsController < ::ViewComponentsController
15
+ prepend_before_action :require_gallery_admin
16
+
17
+ private
18
+
19
+ def require_gallery_admin
20
+ head :not_found unless Studio::ComponentGallery.admin_request?(request)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,19 @@
1
+ // The one rule for "is this a path on THIS site?", in the browser. It is the
2
+ // twin of Studio::LocalPath.local? (lib/studio/local_path.rb), and
3
+ // test/lib/studio/local_path_js_parity_test.rb holds the two to the same rows.
4
+ //
5
+ // A local path begins with exactly one "/" and carries no control character and
6
+ // no backslash. Each clause closes a way a browser leaves the site: no leading
7
+ // "/" is a scheme or a relative path, "//x" is another host, a backslash is read
8
+ // as "/", and the URL parser strips tab and newline, so a control character can
9
+ // hide a second "/".
10
+ //
11
+ // Pinned for every host as "studio/local_path" (config/importmap.rb).
12
+
13
+ // A control character (C0 or DEL) or a backslash anywhere in the path.
14
+ const UNSAFE_CHARACTER = /[\u0000-\u001f\u007f\\]/
15
+
16
+ export function isLocalPath(path) {
17
+ const string = path == null ? "" : String(path)
18
+ return string.startsWith("/") && !string.startsWith("//") && !UNSAFE_CHARACTER.test(string)
19
+ }
@@ -1,37 +1,6 @@
1
- <%# locals: (text:, scheme: "neutral", data_board_count: nil) %>
2
- <%#
3
- Stage roles (stage-fresh ... stage-closed) form a shared pipeline palette
4
- used by News and Content (and any future pipelines). Mapping:
5
- stage-fresh → first stage (blue)
6
- stage-shaping → 2nd stage (yellow)
7
- stage-structured → 3rd stage (mint)
8
- stage-refined → 4th stage (emerald)
9
- stage-cohered → 5th stage (violet)
10
- stage-shipped → published (emerald)
11
- stage-closed → archived/done (gray)
12
- %>
13
- <%
14
- scheme_classes = case scheme.to_s
15
- when "success" then "bg-mint/10 text-mint border-mint/30"
16
- when "danger" then "bg-red-600/10 text-red-400 border-red-600/30"
17
- when "warning" then "bg-yellow-500/10 text-yellow-400 border-yellow-500/30"
18
- when "info" then "bg-blue-500/10 text-blue-500 border-blue-500/30"
19
- when "violet" then "bg-violet/10 text-violet border-violet/30"
20
- when "primary" then "bg-primary/10 text-primary border-primary/30"
21
- when "orange" then "bg-orange-500/10 text-orange-500 border-orange-500/30"
22
- when "emerald" then "bg-emerald-500/10 text-emerald-500 border-emerald-500/30"
23
- when "gray" then "bg-gray-500/10 text-gray-400 border-gray-500/30"
24
- when "stage-fresh" then "bg-blue-500/10 text-blue-500 border-blue-500/30"
25
- when "stage-shaping" then "bg-yellow-500/10 text-yellow-400 border-yellow-500/30"
26
- when "stage-structured" then "bg-mint/10 text-mint border-mint/30"
27
- when "stage-refined" then "bg-emerald-500/10 text-emerald-500 border-emerald-500/30"
28
- when "stage-cohered" then "bg-violet/10 text-violet border-violet/30"
29
- when "stage-shipped" then "bg-emerald-500/10 text-emerald-500 border-emerald-500/30"
30
- when "stage-closed" then "bg-gray-500/10 text-gray-400 border-gray-500/30"
31
- when "neutral" then "bg-surface-alt text-secondary border-subtle"
32
- else "bg-surface-alt text-body border-subtle"
33
- end
34
- %>
35
- <%# data_board_count wires the badge as a studio/board count target (updateCounts sets
36
- its textContent) — additive + opt-in, so every existing caller renders identically. %>
37
- <span class="badge <%= scheme_classes %>"<% if local_assigns[:data_board_count] %> data-board-count="<%= data_board_count %>"<% end %>><%= text %></span>
1
+ <%# locals: (text:, scheme: "neutral", data_board_count: nil) -%>
2
+ <%# The badge is Studio::BadgeComponent; this partial keeps every existing
3
+ `render "components/badge"` call working for one release and renders exactly
4
+ what it rendered before. New code renders the component, which also takes
5
+ tone: (the shared status roles). -%>
6
+ <%= render Studio::BadgeComponent.new(text: text, scheme: scheme, data_board_count: data_board_count) %>
@@ -0,0 +1,16 @@
1
+ <%# The page a component preview renders in, in the gallery and on
2
+ ViewComponent's own preview pages: the host's theme tokens and stylesheets,
3
+ so a preview looks as it does in the app, and nothing else: no navbar, no
4
+ session, no script. -%>
5
+ <!DOCTYPE html>
6
+ <html>
7
+ <head>
8
+ <meta charset="utf-8">
9
+ <meta name="viewport" content="width=device-width,initial-scale=1">
10
+ <%= studio_theme_css_tag if respond_to?(:studio_theme_css_tag) %>
11
+ <%= stylesheet_link_tag "tailwind", "application" %>
12
+ </head>
13
+ <body class="bg-page text-body p-6">
14
+ <%= yield %>
15
+ </body>
16
+ </html>
@@ -0,0 +1,37 @@
1
+ <%# locals: () -%>
2
+ <%# Components: every ViewComponent preview the gallery serves, each state a link
3
+ to its page in the gallery (Lookbook at Studio::ComponentGallery::MOUNT_PATH).
4
+ The previews ARE the specimens, so this list cannot drift from the components.
5
+ An app that does not draw the gallery here (lookbook not in its bundle, or
6
+ production without Studio.lookbook_in_production) says so instead of linking
7
+ to a 404. -%>
8
+ <section id="components" class="space-y-6" style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)">
9
+ <div class="space-y-1">
10
+ <h2 class="text-2xl font-bold text-heading">Components</h2>
11
+ <p class="text-muted text-sm max-w-2xl">
12
+ The engine's ViewComponent classes, one preview per state, in the component gallery.
13
+ </p>
14
+ </div>
15
+
16
+ <% if Studio.lookbook_mounted? %>
17
+ <ul class="grid gap-4 sm:grid-cols-2" data-component-previews>
18
+ <% Lookbook.previews.each do |preview| %>
19
+ <li class="card p-5 space-y-3" data-component-preview="<%= preview.lookup_path %>">
20
+ <p class="font-semibold text-heading"><%= preview.label %></p>
21
+ <div class="flex flex-wrap gap-2">
22
+ <% preview.scenarios.each do |scenario| %>
23
+ <%= link_to scenario.label, scenario.url_path, class: "btn btn-neutral btn-sm" %>
24
+ <% end %>
25
+ </div>
26
+ </li>
27
+ <% end %>
28
+ </ul>
29
+ <%= link_to "Open the component gallery", Studio::ComponentGallery::MOUNT_PATH, class: "btn btn-primary btn-sm" %>
30
+ <% else %>
31
+ <p class="empty-state">
32
+ This app does not serve the component gallery here. It needs lookbook in its bundle
33
+ (<code class="font-mono text-2xs">gem "lookbook"</code> after studio-engine), and in
34
+ production <code class="font-mono text-2xs">Studio.lookbook_in_production</code> too.
35
+ </p>
36
+ <% end %>
37
+ </section>
@@ -3,14 +3,16 @@
3
3
  application.html.erb, inheriting that app's navbar and theme, so every
4
4
  specimen restyles per app and in dark or light automatically.
5
5
 
6
- Four ENGINE sections, reached by the sticky section nav below and rendered
6
+ Five ENGINE sections, reached by the sticky section nav below and rendered
7
7
  as sibling partials: Theme is the landing section (the color foundation - it
8
8
  owns the role tokens/swatches, the folded-in /admin/theme editor, and the
9
9
  live preview), then Modals (the shared host + engine card blocks), Tricks
10
10
  (the button/surface/motion/effect/leveling primitives, framed as a board of
11
- copy-paste-for-an-agent snippets), and Tasks (the shared board primitive).
11
+ copy-paste-for-an-agent snippets), Tasks (the shared board primitive), and
12
+ Components (the ViewComponent previews, linking into the gallery at
13
+ /admin/style/components).
12
14
 
13
- A FIFTH section is the host app's own, and appears only on an app that asks
15
+ A SIXTH section is the host app's own, and appears only on an app that asks
14
16
  for it: define app/views/style/host/_modals.html.erb in the consuming app
15
17
  and the guide grows a section (and a nav pill) for that app's own modals,
16
18
  between Modals and Tricks. The gem still renders the section chrome; the app
@@ -46,7 +48,8 @@
46
48
  sections << ["host-modals", Studio.app_name] if host_modals
47
49
  sections += [
48
50
  ["tricks", "Tricks"],
49
- ["tasks", "Tasks"]
51
+ ["tasks", "Tasks"],
52
+ ["components", "Components"]
50
53
  ]
51
54
  %>
52
55
  <div class="max-w-6xl mx-auto px-4 pb-16">
@@ -87,6 +90,7 @@
87
90
  <% end %>
88
91
  <%= render "style/tricks" %>
89
92
  <%= render "style/tasks" %>
93
+ <%= render "style/components" %>
90
94
  </div>
91
95
  </div>
92
96
 
@@ -0,0 +1,13 @@
1
+ # The engine's importmap pins, drawn into every host by the studio.importmap
2
+ # initializer (lib/studio/engine.rb) before the host's own config/importmap.rb.
3
+ #
4
+ # One `pin` per module under app/javascript/studio, named "studio/<name>", and
5
+ # none preloaded: a page fetches a module only when something imports it.
6
+ #
7
+ # `pin`, not `pin_all_from`, so a host can override one: importmap-rails expands
8
+ # a pin_all_from directory AFTER every plain pin, so a directory pin would beat
9
+ # the host's own `pin` of the same name. Plain pins are last-drawn-wins, and the
10
+ # host's map is drawn last.
11
+ Studio::Engine.javascript_module_logical_paths.each do |logical_path|
12
+ pin logical_path.delete_suffix(".js"), to: logical_path, preload: false
13
+ end
@@ -0,0 +1,66 @@
1
+ # The engine owns the shape of `error_logs`: ErrorLog and the /error_logs pages
2
+ # write and read the columns below on every host.
3
+ #
4
+ # A host that predates this migration already has the table, built by its own
5
+ # migration from docs/NEW_APP_SETUP.md, and the hosts disagree on details (turf
6
+ # has NOT NULL on message, an index on created_at and no unique index on slug).
7
+ # So the migration is a baseline, not a rebuild:
8
+ #
9
+ # - no table: create it in the engine's shape, with its indexes;
10
+ # - a table: add any engine column it lacks, as a plain nullable column, and
11
+ # touch nothing else. No column is changed or dropped, no constraint is
12
+ # tightened and no index is added, because a host's indexes and NOT NULLs
13
+ # are its own, and a unique index built during a release-phase migrate can
14
+ # fail on existing rows or lock a large table.
15
+ #
16
+ # On every consumer that existed when this shipped, the second branch adds
17
+ # nothing. Studio::HostSchema checks the same columns at boot.
18
+ class EnsureErrorLogsTable < ActiveRecord::Migration[7.2]
19
+ def up
20
+ unless table_exists?(:error_logs)
21
+ create_table :error_logs do |t|
22
+ t.string :slug
23
+ t.text :message
24
+ t.text :inspect
25
+ t.text :backtrace
26
+ t.string :target_type
27
+ t.bigint :target_id
28
+ t.string :target_name
29
+ t.string :parent_type
30
+ t.bigint :parent_id
31
+ t.string :parent_name
32
+
33
+ t.timestamps
34
+ end
35
+
36
+ add_index :error_logs, :slug, unique: true
37
+ add_index :error_logs, %i[target_type target_id]
38
+ add_index :error_logs, %i[parent_type parent_id]
39
+ return
40
+ end
41
+
42
+ add_column :error_logs, :slug, :string, if_not_exists: true
43
+ add_column :error_logs, :message, :text, if_not_exists: true
44
+ add_column :error_logs, :inspect, :text, if_not_exists: true
45
+ add_column :error_logs, :backtrace, :text, if_not_exists: true
46
+ add_column :error_logs, :target_type, :string, if_not_exists: true
47
+ add_column :error_logs, :target_id, :bigint, if_not_exists: true
48
+ add_column :error_logs, :target_name, :string, if_not_exists: true
49
+ add_column :error_logs, :parent_type, :string, if_not_exists: true
50
+ add_column :error_logs, :parent_id, :bigint, if_not_exists: true
51
+ add_column :error_logs, :parent_name, :string, if_not_exists: true
52
+ add_column :error_logs, :created_at, :datetime, if_not_exists: true
53
+ add_column :error_logs, :updated_at, :datetime, if_not_exists: true
54
+ end
55
+
56
+ # The migration records nothing about whether it created the table or found
57
+ # it, so a down cannot tell the engine's table from the host's own, and
58
+ # dropping it would destroy the host's error history. It refuses instead.
59
+ def down
60
+ return unless table_exists?(:error_logs)
61
+
62
+ raise ActiveRecord::IrreversibleMigration,
63
+ "EnsureErrorLogsTable cannot tell whether it created error_logs or found the host's own; " \
64
+ "drop the table by hand if this app truly owns none of its rows."
65
+ end
66
+ end
@@ -0,0 +1,63 @@
1
+ # The engine owns the shape of `theme_settings`: ThemeSetting and /admin/theme
2
+ # write the columns below on every host.
3
+ #
4
+ # `slug` is the column this migration exists for. ThemeSetting includes
5
+ # Sluggable, whose before_save writes `slug` on every save, and the setup doc's
6
+ # hand-copied migration omitted it, so a theme save raises on any host built
7
+ # from that doc (cyvasse, mcritchie-industries and moms-app when this shipped).
8
+ # The slug is "theme-<app_name>", derived on save; existing rows keep a NULL
9
+ # slug until their next save, and nothing looks a theme up by slug.
10
+ #
11
+ # A baseline, not a rebuild:
12
+ #
13
+ # - no table: create it in the engine's shape, with its unique app_name index;
14
+ # - a table: add any engine column it lacks, as a plain nullable column, and
15
+ # touch nothing else. No column is changed or dropped, no constraint is
16
+ # tightened and no index is added.
17
+ #
18
+ # Studio::HostSchema checks the same columns at boot.
19
+ class EnsureThemeSettingsTable < ActiveRecord::Migration[7.2]
20
+ def up
21
+ unless table_exists?(:theme_settings)
22
+ create_table :theme_settings do |t|
23
+ t.string :app_name, null: false
24
+ t.string :slug
25
+ t.string :primary
26
+ t.string :dark
27
+ t.string :light
28
+ t.string :accent1
29
+ t.string :accent2
30
+ t.string :warning
31
+ t.string :danger
32
+
33
+ t.timestamps
34
+ end
35
+
36
+ add_index :theme_settings, :app_name, unique: true
37
+ return
38
+ end
39
+
40
+ add_column :theme_settings, :app_name, :string, if_not_exists: true
41
+ add_column :theme_settings, :slug, :string, if_not_exists: true
42
+ add_column :theme_settings, :primary, :string, if_not_exists: true
43
+ add_column :theme_settings, :dark, :string, if_not_exists: true
44
+ add_column :theme_settings, :light, :string, if_not_exists: true
45
+ add_column :theme_settings, :accent1, :string, if_not_exists: true
46
+ add_column :theme_settings, :accent2, :string, if_not_exists: true
47
+ add_column :theme_settings, :warning, :string, if_not_exists: true
48
+ add_column :theme_settings, :danger, :string, if_not_exists: true
49
+ add_column :theme_settings, :created_at, :datetime, if_not_exists: true
50
+ add_column :theme_settings, :updated_at, :datetime, if_not_exists: true
51
+ end
52
+
53
+ # The migration records nothing about whether it created the table or found
54
+ # it, so a down cannot tell the engine's table from the host's own theme. It
55
+ # refuses instead.
56
+ def down
57
+ return unless table_exists?(:theme_settings)
58
+
59
+ raise ActiveRecord::IrreversibleMigration,
60
+ "EnsureThemeSettingsTable cannot tell whether it created theme_settings or found the host's own; " \
61
+ "drop the table or the slug column by hand if this app truly owns neither."
62
+ end
63
+ end
@@ -0,0 +1,63 @@
1
+ # The engine owns the shape of `image_caches`: ImageCache, Studio::ImageCache
2
+ # and Studio::EmailCatalog write the columns below on every host.
3
+ #
4
+ # Hosts that cache images already have the table (from their own migration);
5
+ # hosts that never did (cyvasse and moms-app when this shipped) get it here, so
6
+ # the email banner and logo uploads work on every app.
7
+ #
8
+ # A baseline, not a rebuild:
9
+ #
10
+ # - no table: create it in the engine's shape, with the owner nullable (an
11
+ # app-global image has no owner) and the indexes that back ImageCache's
12
+ # uniqueness validations;
13
+ # - a table: add any engine column it lacks, as a plain nullable column, and
14
+ # touch nothing else. No column is changed or dropped, no constraint is
15
+ # tightened and no index is added.
16
+ #
17
+ # Studio::HostSchema checks the same columns at boot.
18
+ class EnsureImageCachesTable < ActiveRecord::Migration[7.2]
19
+ def up
20
+ unless table_exists?(:image_caches)
21
+ create_table :image_caches do |t|
22
+ t.string :owner_type
23
+ t.bigint :owner_id
24
+ t.string :purpose, null: false
25
+ t.string :variant, null: false
26
+ t.string :s3_key, null: false
27
+ t.string :source_url
28
+ t.string :content_type
29
+ t.integer :bytes
30
+
31
+ t.timestamps
32
+ end
33
+
34
+ add_index :image_caches, %i[owner_type owner_id], name: "index_image_caches_on_owner"
35
+ add_index :image_caches, :s3_key, unique: true
36
+ add_index :image_caches, %i[owner_type owner_id purpose variant], unique: true,
37
+ name: "idx_image_caches_owner_purpose_variant"
38
+ return
39
+ end
40
+
41
+ add_column :image_caches, :owner_type, :string, if_not_exists: true
42
+ add_column :image_caches, :owner_id, :bigint, if_not_exists: true
43
+ add_column :image_caches, :purpose, :string, if_not_exists: true
44
+ add_column :image_caches, :variant, :string, if_not_exists: true
45
+ add_column :image_caches, :s3_key, :string, if_not_exists: true
46
+ add_column :image_caches, :source_url, :string, if_not_exists: true
47
+ add_column :image_caches, :content_type, :string, if_not_exists: true
48
+ add_column :image_caches, :bytes, :integer, if_not_exists: true
49
+ add_column :image_caches, :created_at, :datetime, if_not_exists: true
50
+ add_column :image_caches, :updated_at, :datetime, if_not_exists: true
51
+ end
52
+
53
+ # The migration records nothing about whether it created the table or found
54
+ # it, so a down cannot tell the engine's table from the host's own cache. It
55
+ # refuses instead.
56
+ def down
57
+ return unless table_exists?(:image_caches)
58
+
59
+ raise ActiveRecord::IrreversibleMigration,
60
+ "EnsureImageCachesTable cannot tell whether it created image_caches or found the host's own; " \
61
+ "drop the table by hand if this app truly owns none of its rows."
62
+ end
63
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/security_utils"
4
+ require "active_support/core_ext/object/blank"
5
+ require "active_support/core_ext/module/attribute_accessors"
6
+
7
+ module Studio
8
+ # The component gallery: Lookbook, mounted by Studio.routes at
9
+ # /admin/style/components, rendering the ViewComponent previews under the
10
+ # engine's app/components/previews (docs/FRONT_END_STANDARD.md, "Components").
11
+ #
12
+ # Three rules hold it, each here so the routes, the engine initializers and
13
+ # the preview controller read one answer:
14
+ #
15
+ # 1. WHERE IT IS DRAWN. Only in an app whose bundle loads lookbook (the
16
+ # engine never requires it; a host adds `gem "lookbook"` after
17
+ # studio-engine). Such an app draws it in development and test, and in
18
+ # production only if it also sets Studio.lookbook_in_production = true.
19
+ # Everywhere else there is no route, no ViewComponent preview route, no
20
+ # /lookbook-assets middleware, and no Lookbook in memory.
21
+ # 2. WHO SEES IT. An admin with a live session. Anyone else gets 404, not a
22
+ # redirect, so the gallery's existence is not advertised. The check runs
23
+ # in the router (AdminConstraint), because Lookbook's controllers inherit
24
+ # ActionController::Base and never run the host's before_actions.
25
+ # 3. WHAT IT SHOWS. The rendered preview and its HTML. The Source and Params
26
+ # panels are switched off, embeds are off, pages are off, and no preview
27
+ # declares a @param tag, so the gallery shows no Ruby and accepts no input.
28
+ module ComponentGallery
29
+ MOUNT_PATH = "/admin/style/components"
30
+ # ViewComponent's own preview pages, moved from /rails/view_components to
31
+ # sit behind the same wall (Studio::ComponentPreviewsController).
32
+ PREVIEWS_ROUTE = "/admin/style/previews"
33
+ PREVIEWS_CONTROLLER = "Studio::ComponentPreviewsController"
34
+ # Lookbook's UI references its scripts and styles at this absolute path.
35
+ ASSETS_PATH = "/lookbook-assets"
36
+ # The layout each preview renders in: the host's stylesheets and theme, no
37
+ # navbar, no script.
38
+ PREVIEW_LAYOUT = "studio/component_preview"
39
+ # Only the panels that show output. Lookbook's defaults add :source (the
40
+ # preview's Ruby and template) and :params (live inputs from the URL).
41
+ MAIN_PANELS = %i[preview output].freeze
42
+ DRAWER_PANELS = %i[notes].freeze
43
+
44
+ module_function
45
+
46
+ # Whether the engine saw Lookbook already loaded when it was required, which
47
+ # means the host listed lookbook BEFORE studio-engine (set by
48
+ # lib/studio/engine.rb).
49
+ mattr_accessor :lookbook_loaded_before_engine, default: false
50
+
51
+ # Whether this app draws the gallery. Pure: the env, the flag and whether
52
+ # lookbook is loaded are passed in, so the rule unit-tests without Rails.
53
+ def mounted?(env:, in_production:, lookbook_loaded:)
54
+ return false unless lookbook_loaded
55
+
56
+ !env.to_s.casecmp?("production") || in_production == true
57
+ end
58
+
59
+ # Whether the host's bundle loaded Lookbook. The engine never requires it.
60
+ def lookbook_loaded?
61
+ defined?(::Lookbook::Engine) ? true : false
62
+ end
63
+
64
+ # Lookbook required after view_component, so ViewComponent's after_initialize
65
+ # draws (or does not draw) its preview routes before Lookbook turns previews on.
66
+ def load_order_ok?
67
+ !lookbook_loaded_before_engine
68
+ end
69
+
70
+ # Whether this request comes from an admin with a live session. It reads the
71
+ # same two facts the engine's own gate reads: the user id under
72
+ # Studio.session_key (Studio::ErrorHandling#current_user), and, for a User
73
+ # with a session_token column, a cookie token that matches it
74
+ # (#verify_session_token). It fails closed: no session, no user, a non-admin,
75
+ # or a stale token all answer false.
76
+ def admin_request?(request)
77
+ session = request.session
78
+ key = Studio.session_key
79
+ user_id = session[key.to_s] || session[key.to_sym]
80
+ return false if user_id.blank?
81
+ return false unless defined?(::User)
82
+
83
+ user = ::User.find_by(id: user_id)
84
+ return false unless user.respond_to?(:admin?) && user.admin?
85
+ return true unless user.respond_to?(:session_token)
86
+
87
+ token = user.session_token.to_s
88
+ token.present? && ActiveSupport::SecurityUtils.secure_compare(token, session[:session_token].to_s)
89
+ end
90
+
91
+ # The directory Lookbook's own Rack::Static served ASSETS_PATH from.
92
+ def lookbook_assets_root
93
+ ::Lookbook::Engine.root.join("public/lookbook-assets").to_s
94
+ end
95
+
96
+ # The routing constraint on the mount. A request it refuses matches no
97
+ # route, which Rails answers 404.
98
+ class AdminConstraint
99
+ def matches?(request)
100
+ ComponentGallery.admin_request?(request)
101
+ end
102
+ end
103
+
104
+ # The settings that keep the gallery to output only (rule 3). Applied by
105
+ # the studio.component_gallery initializer, before the host's own
106
+ # initializers, so a host can still change one deliberately.
107
+ def configure_lookbook!(config)
108
+ config.project_name = "Components"
109
+ config.preview_inspector.main_panels = MAIN_PANELS
110
+ config.preview_inspector.drawer_panels = DRAWER_PANELS
111
+ config.preview_embeds.enabled = false
112
+ config.page_paths = []
113
+ config.live_updates = false
114
+ config.debug_menu = false
115
+ # Parse previews on the first gallery request, not at boot: an app that
116
+ # never opens the gallery pays nothing for it.
117
+ config.lazy_load_previews_and_pages = true
118
+ config
119
+ end
120
+ end
121
+ end
data/lib/studio/engine.rb CHANGED
@@ -1,4 +1,16 @@
1
1
  require_relative "log_rotation"
2
+ require_relative "component_gallery"
3
+ # Components (docs/FRONT_END_STANDARD.md). Required here, not left to the host's
4
+ # Gemfile, because every app takes ViewComponent through this engine.
5
+ #
6
+ # Lookbook, the gallery, is NOT required here: an app that wants it bundles it
7
+ # (see the gemspec), and Bundler.require loads it. Listed AFTER studio-engine, so
8
+ # view_component is required first: its after_initialize, which decides whether
9
+ # to draw its own preview routes, must run before Lookbook's, which turns
10
+ # previews on for its own use. Studio::ComponentGallery.load_order_ok? records
11
+ # the order this file saw.
12
+ Studio::ComponentGallery.lookbook_loaded_before_engine = defined?(::Lookbook::Engine) ? true : false
13
+ require "view_component"
2
14
 
3
15
  module Studio
4
16
  class Engine < ::Rails::Engine
@@ -71,6 +83,77 @@ module Studio
71
83
  # US set ships — every other country's badge renders an emoji flag, which
72
84
  # costs no bytes at all.
73
85
  app.config.assets.precompile += Studio::Engine.subdivision_flag_logical_paths
86
+
87
+ # The engine's ES modules (app/javascript), which config/importmap.rb pins.
88
+ # On the asset paths so an importmap pin resolves on sprockets and propshaft
89
+ # alike, and enumerated for sprockets' precompile list like the banners.
90
+ javascript_root = Studio::Engine.root.join("app/javascript").to_s
91
+ if app.config.assets.respond_to?(:paths) && app.config.assets.paths.is_a?(Array)
92
+ app.config.assets.paths << javascript_root unless app.config.assets.paths.map(&:to_s).include?(javascript_root)
93
+ end
94
+ app.config.assets.precompile += Studio::Engine.javascript_module_logical_paths
95
+ end
96
+
97
+ # ---- THE ENGINE'S IMPORTMAP PINS ------------------------------------------
98
+ #
99
+ # A host draws the engine's pins with no edit of its own: this adds the
100
+ # engine's config/importmap.rb to importmap-rails' list of maps BEFORE
101
+ # importmap-rails draws them (its "importmap" initializer appends the host's
102
+ # config/importmap.rb last). The host's map is drawn after the engine's, so a
103
+ # host that pins the same name wins. This is the mechanism importmap-rails
104
+ # documents for engines ("Composing import maps").
105
+ #
106
+ # The contract a module joins by being here:
107
+ # - it lives under app/javascript/studio/ and is pinned as "studio/<name>";
108
+ # - pins are NOT preloaded, so a page fetches only the modules it imports;
109
+ # - its logical asset path shares the studio/ prefix with the classic
110
+ # scripts in app/assets/javascripts/studio, so a module may not reuse one
111
+ # of those names (test/integration/engine_importmap_pins_test.rb).
112
+ # A host without importmap-rails (a footer-only consumer) skips this.
113
+ initializer "studio.importmap", before: "importmap" do |app|
114
+ next unless app.config.respond_to?(:importmap)
115
+
116
+ app.config.importmap.paths << Studio::Engine.root.join("config/importmap.rb")
117
+ app.config.importmap.cache_sweepers << Studio::Engine.root.join("app/javascript")
118
+ end
119
+
120
+ # ---- COMPONENTS AND THE GALLERY -------------------------------------------
121
+ #
122
+ # ViewComponent reads its preview settings in "view_component.set_configs",
123
+ # so these land first. The previews live in app/components/previews, which
124
+ # is its own autoload root (below): Studio::BadgeComponentPreview, not
125
+ # Previews::Studio::BadgeComponentPreview. ViewComponent's own preview pages
126
+ # move behind the admin wall (PREVIEWS_ROUTE, PREVIEWS_CONTROLLER), and each
127
+ # preview renders in a layout carrying the host's stylesheets.
128
+ paths.add "app/components/previews", autoload: true
129
+
130
+ initializer "studio.view_component", before: "view_component.set_configs" do |app|
131
+ previews = app.config.view_component.previews
132
+ preview_path = Studio::Engine.root.join("app/components/previews").to_s
133
+ previews.paths << preview_path unless previews.paths.include?(preview_path)
134
+ previews.route = Studio::ComponentGallery::PREVIEWS_ROUTE
135
+ previews.controller = Studio::ComponentGallery::PREVIEWS_CONTROLLER
136
+ previews.default_layout = Studio::ComponentGallery::PREVIEW_LAYOUT
137
+ end
138
+
139
+ # Lookbook's settings, before Lookbook's after_initialize reads them and
140
+ # before the host's config/initializers, so a host can still change one.
141
+ #
142
+ # It also takes Lookbook's UI assets off the public middleware stack.
143
+ # Lookbook serves /lookbook-assets through a Rack::Static it adds for every
144
+ # request; Studio.routes serves the same files from inside the admin
145
+ # constraint instead, so an app that does not draw the gallery serves none
146
+ # of Lookbook and a visitor never learns it is there. The delete matches by
147
+ # class, so a host cannot add a Rack::Static of its own (none does).
148
+ initializer "studio.component_gallery", after: "lookbook.assets.serve" do
149
+ next unless Studio::ComponentGallery.lookbook_loaded?
150
+
151
+ unless Studio::ComponentGallery.load_order_ok?
152
+ warn "studio-engine: lookbook was required before studio-engine; list it after " \
153
+ "studio-engine in the Gemfile so ViewComponent decides its preview routes first"
154
+ end
155
+ Studio::ComponentGallery.configure_lookbook!(::Lookbook.config)
156
+ config.app_middleware.delete(::Rack::Static)
74
157
  end
75
158
 
76
159
  # Logical asset paths ("emails/magic-link.png") for every default banner the
@@ -82,6 +165,16 @@ module Studio
82
165
  .sort
83
166
  end
84
167
 
168
+ # Logical asset paths ("studio/local_path.js") for every ES module the
169
+ # engine pins (config/importmap.rb).
170
+ def self.javascript_module_logical_paths
171
+ base = File.expand_path("../../app/javascript", __dir__)
172
+ Dir[File.join(base, "**/*.js")]
173
+ .select { |path| File.file?(path) }
174
+ .map { |path| path.delete_prefix("#{base}/") }
175
+ .sort
176
+ end
177
+
85
178
  # Logical asset paths ("state-flags/wa.svg") for every subdivision flag the
86
179
  # gem ships.
87
180
  # Both the vector art and the small rasters behind it: the badge renders one
@@ -205,6 +298,14 @@ module Studio
205
298
  ::User.ancestors.include?(::ActiveRecord::Base)
206
299
  Studio.validate_user_contract!(::User)
207
300
  end
301
+
302
+ # The host's error_logs, theme_settings and image_caches carry every
303
+ # column the engine's models write (Studio::HostSchema). Skipped in a
304
+ # rake task, which boots to migrate rather than to serve.
305
+ if Studio.active_record? && !Studio::HostSchema.inside_rake_task?
306
+ Studio::HostSchema.check!
307
+ end
208
308
  end
309
+
209
310
  end
210
311
  end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # The columns the engine's models write on tables the HOST holds: error_logs,
5
+ # theme_settings and image_caches. The engine's Ensure*Table migrations create
6
+ # or complete those tables; this checks, at boot, that the host has actually
7
+ # run them, so a missing column is named at startup instead of surfacing later
8
+ # as a 500 on the first save that writes it (a theme save on a host without
9
+ # theme_settings.slug was the case that prompted it).
10
+ #
11
+ # The check is one-directional: it looks only for REQUIRED columns that are
12
+ # absent. Extra columns, extra indexes, a different NOT NULL or a different
13
+ # type are the host's business and never reported.
14
+ #
15
+ # HOW LOUD, per environment (Studio.host_schema_check):
16
+ #
17
+ # :raise development and test, by default. A developer or a CI suite sees
18
+ # Studio::HostSchemaError at boot with the table, the column and the
19
+ # two commands that fix it.
20
+ # :log every other environment (production, and QA, which runs as
21
+ # production), by default. Rails.logger.error plus a Sentry message
22
+ # when the host loads sentry-ruby. It never raises there, for two
23
+ # reasons. Heroku's release phase boots the app to run db:migrate,
24
+ # the very command that adds the column, so a raise at boot would
25
+ # make the fix undeployable. And a missing column breaks the one
26
+ # surface that writes it (an admin theme save, an email banner
27
+ # upload); taking every page down to report it trades a small
28
+ # outage for a total one.
29
+ # false off.
30
+ #
31
+ # The check never runs inside a rake task (db:migrate, install:migrations,
32
+ # assets:precompile): those boot the app precisely to change or ignore the
33
+ # schema, and a report there would be wrong by the time the task finishes. It
34
+ # also stays quiet when the database cannot be reached; it has nothing to say
35
+ # about a schema it cannot read.
36
+ module HostSchema
37
+ REQUIRED_COLUMNS = {
38
+ "error_logs" => %w[
39
+ slug message inspect backtrace
40
+ target_type target_id target_name
41
+ parent_type parent_id parent_name
42
+ created_at updated_at
43
+ ].freeze,
44
+ "theme_settings" => %w[
45
+ app_name slug primary dark light accent1 accent2 warning danger
46
+ created_at updated_at
47
+ ].freeze,
48
+ "image_caches" => %w[
49
+ owner_type owner_id purpose variant s3_key source_url content_type bytes
50
+ created_at updated_at
51
+ ].freeze
52
+ }.freeze
53
+
54
+ MODES = [:raise, :log, false].freeze
55
+
56
+ module_function
57
+
58
+ # { "table" => ["missing", "columns"] } for every required table that is
59
+ # absent (all its columns listed) or short of a column. Empty when the host
60
+ # satisfies the contract.
61
+ def missing_columns(connection)
62
+ REQUIRED_COLUMNS.each_with_object({}) do |(table, required), missing|
63
+ present = connection.table_exists?(table) ? connection.columns(table).map(&:name) : []
64
+ absent = required - present
65
+ missing[table] = absent if absent.any?
66
+ end
67
+ end
68
+
69
+ def message(missing)
70
+ lines = missing.map do |table, columns|
71
+ if columns == REQUIRED_COLUMNS[table]
72
+ " #{table}: the table is missing"
73
+ else
74
+ " #{table}: missing #{columns.join(', ')}"
75
+ end
76
+ end
77
+
78
+ <<~MSG
79
+ studio-engine: this app's database is missing columns the engine's models write.
80
+
81
+ #{lines.join("\n")}
82
+
83
+ Install and run the engine's migrations (they create a missing table, add
84
+ a missing column and change nothing else):
85
+
86
+ bin/rails studio_engine:install:migrations
87
+ bin/rails db:migrate
88
+
89
+ Set Studio.host_schema_check = false in config/initializers/studio.rb to
90
+ silence this check.
91
+ MSG
92
+ end
93
+
94
+ # The mode in force: the host's Studio.host_schema_check, or :raise in
95
+ # development and test and :log everywhere else.
96
+ def mode(setting: Studio.host_schema_check, env: Rails.env)
97
+ return setting unless setting.nil?
98
+
99
+ env.development? || env.test? ? :raise : :log
100
+ end
101
+
102
+ # The boot hook. Returns the missing-columns hash it found (empty when the
103
+ # host is whole or the check was skipped) and acts on it per `mode`.
104
+ def check!(connection: nil, mode: self.mode, logger: Rails.logger)
105
+ return {} if mode == false
106
+
107
+ missing =
108
+ begin
109
+ if connection
110
+ missing_columns(connection)
111
+ else
112
+ ActiveRecord::Base.connection_pool.with_connection { |conn| missing_columns(conn) }
113
+ end
114
+ rescue ActiveRecord::ActiveRecordError => e
115
+ logger&.debug("[studio-engine] host schema check skipped: #{e.class}: #{e.message}")
116
+ return {}
117
+ end
118
+ return missing if missing.empty?
119
+
120
+ text = message(missing)
121
+ raise Studio::HostSchemaError, text if mode == :raise
122
+
123
+ logger&.error("[studio-engine] #{text}")
124
+ report_to_sentry(text)
125
+ missing
126
+ end
127
+
128
+ # True while a rake task is running: the app booted to migrate, install or
129
+ # precompile, not to serve.
130
+ def inside_rake_task?
131
+ defined?(::Rake) && ::Rake.respond_to?(:application) &&
132
+ ::Rake.application.respond_to?(:top_level_tasks) &&
133
+ ::Rake.application.top_level_tasks.any?
134
+ end
135
+
136
+ def report_to_sentry(text)
137
+ return unless defined?(::Sentry) && ::Sentry.respond_to?(:capture_message)
138
+
139
+ ::Sentry.capture_message(text, level: :error)
140
+ rescue StandardError
141
+ nil
142
+ end
143
+ end
144
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.91.2"
2
+ VERSION = "0.92.1"
3
3
  end
data/lib/studio.rb CHANGED
@@ -13,6 +13,7 @@ require "active_support/core_ext/numeric/time"
13
13
  require "active_support/core_ext/integer/time"
14
14
  require "studio/version"
15
15
  require "studio/local_path"
16
+ require "studio/component_gallery"
16
17
  require "studio/log_rotation"
17
18
  require "studio/ip_locations"
18
19
  require "studio/geo"
@@ -41,6 +42,7 @@ require "studio/public_user"
41
42
  require "studio/name_parts"
42
43
  require "studio/s3"
43
44
  require "studio/image_cache"
45
+ require "studio/host_schema"
44
46
  require "studio/link_token"
45
47
  require "studio/link_resolution"
46
48
  require "studio/session_fingerprint"
@@ -690,6 +692,22 @@ module Studio
690
692
  # config.draw_booking_routes = true
691
693
  mattr_accessor :draw_booking_routes, default: false
692
694
 
695
+ # Draw the component gallery (Lookbook at /admin/style/components) in
696
+ # PRODUCTION. An app whose bundle loads lookbook draws it in development and
697
+ # test; in production only an app that also sets this does. No app sets it
698
+ # yet; the hub is meant to, with lookbook in its production bundle. Admins
699
+ # only, 404 to anyone else. See Studio::ComponentGallery.
700
+ #
701
+ # config.lookbook_in_production = true
702
+ mattr_accessor :lookbook_in_production, default: false
703
+
704
+ # Whether this app draws the component gallery: its bundle loaded lookbook,
705
+ # and this is development or test, or production with the flag.
706
+ def self.lookbook_mounted?
707
+ ComponentGallery.mounted?(env: Rails.env, in_production: lookbook_in_production,
708
+ lookbook_loaded: ComponentGallery.lookbook_loaded?)
709
+ end
710
+
693
711
  # WHERE THE APP'S OWN BOOKING PAGE LIVES, for an app that renders
694
712
  # `studio_booking_frame` in a view of its own instead of drawing the engine's
695
713
  # /schedule: a path ("/schedule"), or a callable receiving the view
@@ -1043,6 +1061,11 @@ module Studio
1043
1061
  # that intentionally break the contract).
1044
1062
  mattr_accessor :validate_user_contract, default: true
1045
1063
 
1064
+ # How loudly the boot check reports a host database missing a column the
1065
+ # engine's models write (Studio::HostSchema): :raise, :log, or false for off.
1066
+ # nil, the default, means :raise in development and test and :log elsewhere.
1067
+ mattr_accessor :host_schema_check, default: nil
1068
+
1046
1069
  # Does the host load ActiveRecord at all? True for every app that requires
1047
1070
  # `rails/all` or `active_record/railtie` (all the engine's database-backed
1048
1071
  # consumers); false for a FOOTER-ONLY consumer, an app with no database that
@@ -1074,6 +1097,7 @@ module Studio
1074
1097
  PASSWORD_USER_INSTANCE_METHODS = %i[authenticate].freeze
1075
1098
 
1076
1099
  class UserContractError < StandardError; end
1100
+ class HostSchemaError < StandardError; end
1077
1101
 
1078
1102
  def self.configure
1079
1103
  yield self
@@ -1531,6 +1555,19 @@ module Studio
1531
1555
  get "admin/style", to: "style#index", as: :admin_style
1532
1556
  get "admin/design_system", to: redirect("/admin/style"), as: :admin_design_system
1533
1557
 
1558
+ # The component gallery: Lookbook over the engine's ViewComponent previews.
1559
+ # The router refuses anyone but a signed-in admin, so a visitor and a
1560
+ # non-admin get 404 (Studio::ComponentGallery::AdminConstraint). Drawn
1561
+ # only where the host's bundle loaded lookbook: in development and test,
1562
+ # and in production only where Studio.lookbook_in_production is set.
1563
+ if Studio.lookbook_mounted?
1564
+ constraints(Studio::ComponentGallery::AdminConstraint.new) do
1565
+ mount Lookbook::Engine, at: Studio::ComponentGallery::MOUNT_PATH, as: :studio_component_gallery
1566
+ mount Rack::Files.new(Studio::ComponentGallery.lookbook_assets_root),
1567
+ at: Studio::ComponentGallery::ASSETS_PATH, as: :studio_component_gallery_assets
1568
+ end
1569
+ end
1570
+
1534
1571
  # The standard transactional-email page. Canonical at /admin/emails
1535
1572
  # (Studio::EmailsController): index lists every registered email with its
1536
1573
  # live banner and whether that banner is inherited or app-owned; update
@@ -19,9 +19,11 @@ Gem::Specification.new do |spec|
19
19
  }
20
20
 
21
21
  # config/e2e_lane.yml is the BROWSER LANE's contract — a fact about this repo's CI,
22
- # read by bin/e2e-executed-set-check and test/lib/e2e_lane_contract_test.rb. It is
23
- # the only thing under config/, and it has no meaning in a consuming app, so it is
24
- # excluded rather than shipped. (e2e/, bin/ and test/ were never in this list.)
22
+ # read by bin/e2e-executed-set-check and test/lib/e2e_lane_contract_test.rb. It
23
+ # has no meaning in a consuming app, so it is excluded rather than shipped.
24
+ # config/importmap.rb ships: it is the engine's pins, which every host draws
25
+ # (lib/studio/engine.rb, studio.importmap). (e2e/, bin/ and test/ were never in
26
+ # this list.)
25
27
  #
26
28
  # docs/SITE_FOOTER.md and docs/BOOKING.md ship because consumers cite them from
27
29
  # their own initializers ("docs/SITE_FOOTER.md is the contract"), and a
@@ -56,4 +58,16 @@ Gem::Specification.new do |spec|
56
58
  # because the point of the geo primitive is that EVERY app has the capacity:
57
59
  # an app that has to add a gem before it can place a visitor does not.
58
60
  spec.add_dependency "geocoder", ">= 1.8", "< 2.0"
61
+ # Components (docs/FRONT_END_STANDARD.md). Every consumer renders the engine's
62
+ # ViewComponent classes, so it is a runtime dependency, and an app takes it
63
+ # through the engine rather than its own Gemfile.
64
+ spec.add_dependency "view_component", ">= 4.0", "< 5"
65
+ # The component gallery at /admin/style/components is Lookbook, and Lookbook is
66
+ # NOT a runtime dependency: eager-loaded in production it costs every process
67
+ # tens of megabytes, and most consumers run on 512 MB dynos. An app opts in by
68
+ # bundling it after studio-engine, `gem "lookbook", group: [:development, :test]`
69
+ # for a development gallery, ungrouped plus Studio.lookbook_in_production for a
70
+ # live one. The engine never requires it; it mounts the gallery only when the
71
+ # host's bundle has loaded it. See Studio::ComponentGallery.
72
+ spec.add_development_dependency "lookbook", ">= 2.3", "< 3"
59
73
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.91.2
4
+ version: 0.92.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-06 00:00:00.000000000 Z
11
+ date: 2026-10-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -180,6 +180,46 @@ dependencies:
180
180
  - - "<"
181
181
  - !ruby/object:Gem::Version
182
182
  version: '2.0'
183
+ - !ruby/object:Gem::Dependency
184
+ name: view_component
185
+ requirement: !ruby/object:Gem::Requirement
186
+ requirements:
187
+ - - ">="
188
+ - !ruby/object:Gem::Version
189
+ version: '4.0'
190
+ - - "<"
191
+ - !ruby/object:Gem::Version
192
+ version: '5'
193
+ type: :runtime
194
+ prerelease: false
195
+ version_requirements: !ruby/object:Gem::Requirement
196
+ requirements:
197
+ - - ">="
198
+ - !ruby/object:Gem::Version
199
+ version: '4.0'
200
+ - - "<"
201
+ - !ruby/object:Gem::Version
202
+ version: '5'
203
+ - !ruby/object:Gem::Dependency
204
+ name: lookbook
205
+ requirement: !ruby/object:Gem::Requirement
206
+ requirements:
207
+ - - ">="
208
+ - !ruby/object:Gem::Version
209
+ version: '2.3'
210
+ - - "<"
211
+ - !ruby/object:Gem::Version
212
+ version: '3'
213
+ type: :development
214
+ prerelease: false
215
+ version_requirements: !ruby/object:Gem::Requirement
216
+ requirements:
217
+ - - ">="
218
+ - !ruby/object:Gem::Version
219
+ version: '2.3'
220
+ - - "<"
221
+ - !ruby/object:Gem::Version
222
+ version: '3'
183
223
  description: Studio Engine is a non-isolated Rails engine that ships an opinionated
184
224
  authentication + SSO contract, a polymorphic ErrorLog model, a Sluggable concern,
185
225
  a 7-role dynamic theme system with CSS-custom-property generation, and an S3-backed
@@ -318,6 +358,11 @@ files:
318
358
  - app/assets/stylesheets/studio/sticky_table_header.css
319
359
  - app/assets/tailwind/studio_engine/engine-motion.css
320
360
  - app/assets/tailwind/studio_engine/engine.css
361
+ - app/components/previews/studio/badge_component_preview.rb
362
+ - app/components/previews/studio/badge_component_preview/schemes.html.erb
363
+ - app/components/previews/studio/badge_component_preview/tones.html.erb
364
+ - app/components/studio/badge_component.html.erb
365
+ - app/components/studio/badge_component.rb
321
366
  - app/controllers/concerns/solana/session_auth.rb
322
367
  - app/controllers/concerns/studio/admin_models.rb
323
368
  - app/controllers/concerns/studio/board/reorderable.rb
@@ -337,6 +382,7 @@ files:
337
382
  - app/controllers/solana_sessions_controller.rb
338
383
  - app/controllers/studio/admin_surveys_controller.rb
339
384
  - app/controllers/studio/bookings_controller.rb
385
+ - app/controllers/studio/component_previews_controller.rb
340
386
  - app/controllers/studio/emails_controller.rb
341
387
  - app/controllers/studio/geo_settings_controller.rb
342
388
  - app/controllers/studio/knowledge_docs_controller.rb
@@ -363,6 +409,7 @@ files:
363
409
  - app/helpers/studio_navbar_helper.rb
364
410
  - app/helpers/studio_sidebar_helper.rb
365
411
  - app/helpers/studio_theme_helper.rb
412
+ - app/javascript/studio/local_path.js
366
413
  - app/jobs/error_log_cleanup_job.rb
367
414
  - app/jobs/studio/email_delivery_job.rb
368
415
  - app/mailers/application_mailer.rb
@@ -416,6 +463,7 @@ files:
416
463
  - app/views/layouts/studio/_head.html.erb
417
464
  - app/views/layouts/studio/_link_preview_tags.html.erb
418
465
  - app/views/layouts/studio/_smooth_load.html.erb
466
+ - app/views/layouts/studio/component_preview.html.erb
419
467
  - app/views/navbar/show.html.erb
420
468
  - app/views/registrations/new.html.erb
421
469
  - app/views/schema/index.html.erb
@@ -548,6 +596,7 @@ files:
548
596
  - app/views/studio/surveys/show.html.erb
549
597
  - app/views/studio/surveys/sign_in_required.html.erb
550
598
  - app/views/studio/surveys/thanks.html.erb
599
+ - app/views/style/_components.html.erb
551
600
  - app/views/style/_host.html.erb
552
601
  - app/views/style/_modal_specimen.html.erb
553
602
  - app/views/style/_modals.html.erb
@@ -575,6 +624,7 @@ files:
575
624
  - app/views/theme_settings/edit.html.erb
576
625
  - app/views/user_mailer/magic_link.html.erb
577
626
  - app/views/user_mailer/magic_link.text.erb
627
+ - config/importmap.rb
578
628
  - db/migrate/20260614000000_create_studio_email_deliveries.rb
579
629
  - db/migrate/20260620000001_create_studio_links.rb
580
630
  - db/migrate/20260620000002_allow_null_image_cache_owner.rb
@@ -590,6 +640,9 @@ files:
590
640
  - db/migrate/20260902000002_add_expectation_to_studio_knowledge_docs.rb
591
641
  - db/migrate/20260930120000_create_studio_site_identities.rb
592
642
  - db/migrate/20261005120000_create_studio_survey_responses.rb
643
+ - db/migrate/20261006120001_ensure_error_logs_table.rb
644
+ - db/migrate/20261006120002_ensure_theme_settings_table.rb
645
+ - db/migrate/20261006120003_ensure_image_caches_table.rb
593
646
  - docs/BOOKING.md
594
647
  - docs/SITE_FOOTER.md
595
648
  - lib/active_storage/service/studio_trash_s3_service.rb
@@ -600,6 +653,7 @@ files:
600
653
  - lib/studio/booking.rb
601
654
  - lib/studio/cable.rb
602
655
  - lib/studio/color_scale.rb
656
+ - lib/studio/component_gallery.rb
603
657
  - lib/studio/email.rb
604
658
  - lib/studio/email_smoke.rb
605
659
  - lib/studio/engine.rb
@@ -607,6 +661,7 @@ files:
607
661
  - lib/studio/geo.rb
608
662
  - lib/studio/geo/countries.rb
609
663
  - lib/studio/geo/lookup.rb
664
+ - lib/studio/host_schema.rb
610
665
  - lib/studio/image_cache.rb
611
666
  - lib/studio/ip_locations.rb
612
667
  - lib/studio/js_identifier.rb