studio-engine 0.91.1 → 0.92.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b177cff9fc53b20d566685aa744f1dc41f481017f3014c9a00ef71a43620365a
4
- data.tar.gz: 100753f4d2f9dd2f936c8285a2b3aac844d2538099b066ef15013ca77f407392
3
+ metadata.gz: 823f5e3df6cb271556188c72d4322a968b5cf63f8635c37b70fc71a56ad93fe3
4
+ data.tar.gz: ab908a4f8a0c9beae55f1844bbc2957b43c7bc33f1643c3ae083473ae68473d7
5
5
  SHA512:
6
- metadata.gz: 76433dc917873ff167918968f9ec85d1d6765606e58f3c6502183cab9fca55b3b19a4bc74652f93c74222c3799e4cedbc411a7ce79c6d4d8ff207524982dca20
7
- data.tar.gz: 3c69e3e3ed94f63952f9cbf2c2e75446b3d95a7d63c51e5b7331dce801bc5ae5329761b456f6cd968e276d6874daf453dcce551c20b87960c49a604eda81d80e
6
+ metadata.gz: 0f66ecc8ebf3c1b6318e88e4b069cb2bc4b19452db48035558eea01cb5cf163fcf126435dfdc8b74d5e837bcd65535a10b99157e9cec1fa286f2452739f420ba
7
+ data.tar.gz: a3ae0c907619c2ba686bdb3a35a82883bd1a15e010191969e52c102414e37b57982a19de4cba20ccf20866638d6814983ad06e6fe0f7027ad8bad925caba6c06
data/CHANGELOG.md CHANGED
@@ -4,6 +4,60 @@ 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.0 — 2026-10-06
8
+
9
+ ### Added
10
+
11
+ - **ViewComponent comes with the engine.** It is a runtime dependency, so an
12
+ app renders the engine's components with no Gemfile line of its own. Lookbook
13
+ is not: an app that wants the component gallery adds
14
+ `gem "lookbook", group: [:development, :test]` after studio-engine (ungrouped,
15
+ with `Studio.lookbook_in_production`, for a live gallery). Without it the app
16
+ loads no Lookbook code, which keeps tens of megabytes out of every production
17
+ process. The front-end standard (`docs/FRONT_END_STANDARD.md`) now names a
18
+ primitive's public surface (its inputs, its `data-*` hooks and its named CSS
19
+ classes) and what a change to it takes: a Breaking line here and a
20
+ consumer-CI run against every consumer before publish.
21
+ - **`Studio::BadgeComponent`, the first component.** It takes `text:`,
22
+ `data_board_count:`, and either `tone:` (one of the five status roles,
23
+ `success`, `warning`, `danger`, `primary`, `muted`, with the same chip classes
24
+ as the hub's `status_tone`) or `scheme:` (the palette the badge partial took).
25
+ The `components/badge` partial now renders the component and its output is
26
+ unchanged, so no caller changes this release. `engine.css` adds the engine's
27
+ `app/components` to every host's Tailwind scan.
28
+ - **The component gallery at `/admin/style/components`.** Lookbook over the
29
+ engine's previews, with a Components section on `/admin/style` linking each
30
+ state. A signed-in admin gets it; a visitor or non-admin gets 404. It shows
31
+ the rendered output only (no Source or Params panel). It is drawn only where
32
+ the app's bundle loads lookbook: in development and test, and in production
33
+ only with `Studio.lookbook_in_production = true`. No app draws ViewComponent's
34
+ preview routes in production. ViewComponent's own preview pages move to
35
+ `/admin/style/previews`, behind the same wall.
36
+ - **The engine's own importmap pins.** The engine's `config/importmap.rb` pins
37
+ each module under `app/javascript/studio` as `studio/<name>` (not preloaded),
38
+ and the `studio.importmap` initializer draws it into every host that has
39
+ importmap-rails, before the host's own map, so a host pin of the same name
40
+ wins. No host edit. The first module is `studio/local_path`, the browser twin
41
+ of `Studio::LocalPath.local?`.
42
+ - **Consumer CI runs Cyvasse's suite** against each engine change, beside the
43
+ hub, Turf Monster and McRitchie Industries.
44
+
45
+ ## 0.91.2 — 2026-10-06
46
+
47
+ ### Security
48
+
49
+ - **A return path beginning `/\` no longer redirects off-site.** Browsers read
50
+ `/\evil.com` as `//evil.com`, and the engine's path sanitizers checked only for
51
+ one leading `/` and not two, so a magic link minted with `return_to=/\evil.com`
52
+ sent the visitor to another host after sign-in (or, under Rails'
53
+ `raise_on_open_redirects`, answered 500). One predicate, `Studio::LocalPath.local?`,
54
+ now holds the rule (one leading `/`, no backslash, no control character) and
55
+ every engine site uses it: `Studio::LinkToken.sanitize_path`, the link-consume
56
+ redirect, `Studio::Booking.local_path?`, and the link-preview fallback image.
57
+ Each site keeps its fallback: a refused return path lands on the default page.
58
+ Consumers that sanitize a return path themselves should call
59
+ `Studio::LocalPath.local?` instead of a bare `start_with?("/")` check.
60
+
7
61
  ## 0.91.1 — 2026-10-06
8
62
 
9
63
  ### Fixed
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
@@ -163,10 +163,10 @@ module Studio
163
163
  user.update!(email_verified_at: Time.current)
164
164
  end
165
165
 
166
- # Only same-origin absolute paths survive; everything else collapses to nil.
166
+ # Only local paths survive (Studio::LocalPath); everything else collapses to nil.
167
167
  def safe_path(path)
168
168
  p = path.to_s
169
- p.start_with?("/") && !p.start_with?("//") ? p : nil
169
+ Studio::LocalPath.local?(p) ? p : nil
170
170
  end
171
171
  end
172
172
  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
+ }
@@ -183,7 +183,7 @@ module Studio
183
183
  def static_image
184
184
  path = Studio.link_preview_fallback_image.to_s
185
185
  return nil if path.empty?
186
- return path unless path.start_with?("/") && !path.start_with?("//")
186
+ return path unless Studio::LocalPath.local?(path)
187
187
 
188
188
  @static_files ||= Concurrent::Map.new
189
189
  present = @static_files.compute_if_absent(path) do
@@ -203,7 +203,7 @@ module Studio
203
203
  # per path like static_image.
204
204
  def static_image_dimensions
205
205
  path = static_image
206
- return nil if path.nil? || !path.start_with?("/") || path.start_with?("//")
206
+ return nil unless Studio::LocalPath.local?(path)
207
207
 
208
208
  @static_dimensions ||= Concurrent::Map.new
209
209
  dims = @static_dimensions.compute_if_absent(path) do
@@ -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
@@ -11,6 +11,8 @@
11
11
  #
12
12
  # `gv=true` is what makes Google serve the embeddable page, so the frame and the
13
13
  # popup add it; the "open the booking page" link does not.
14
+ require_relative "local_path"
15
+
14
16
  module Studio
15
17
  module Booking
16
18
  EMBED_PARAM = "gv=true"
@@ -116,10 +118,10 @@ module Studio
116
118
  # hrefs (the footer's booking links, `studio_booking_link`). It begins with
117
119
  # one "/" and carries no control character. That refuses a scheme
118
120
  # ("javascript:", "https:") and a protocol-relative "//host", which a browser
119
- # reads as another site.
121
+ # reads as another site. The rule is Studio::LocalPath's, shared with every
122
+ # other engine path sanitizer.
120
123
  def local_path?(path)
121
- string = path.to_s
122
- string.start_with?("/") && !string.start_with?("//") && !string.match?(/[\x00-\x1f\x7f\\]/)
124
+ Studio::LocalPath.local?(path)
123
125
  end
124
126
 
125
127
  # Studio.booking_path: nil, a local path, or a callable receiving the view.
@@ -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
@@ -206,5 +299,6 @@ module Studio
206
299
  Studio.validate_user_contract!(::User)
207
300
  end
208
301
  end
302
+
209
303
  end
210
304
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "securerandom"
4
+ require_relative "local_path"
4
5
 
5
6
  module Studio
6
7
  # Pure-Ruby helpers behind the Studio::Link model — token minting, the kind
@@ -53,12 +54,13 @@ module Studio
53
54
  email.to_s.strip.downcase
54
55
  end
55
56
 
56
- # Only same-origin absolute paths survive; protocol-relative ("//evil"),
57
+ # Only local paths survive (Studio::LocalPath: one leading "/", no
58
+ # backslash, no control character); protocol-relative ("//evil", "/\evil"),
57
59
  # absolute URLs, and blanks collapse to nil so callers fall back to a safe
58
- # default redirect. Mirrors the MagicLink service's sanitizer.
60
+ # default redirect.
59
61
  def sanitize_path(path)
60
62
  p = path.to_s
61
- p.start_with?("/") && !p.start_with?("//") ? p : nil
63
+ Studio::LocalPath.local?(p) ? p : nil
62
64
  end
63
65
  end
64
66
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # The one rule for "is this a path on THIS site?", used wherever the engine
5
+ # writes a caller-supplied path into a redirect or an href: a sign-in's
6
+ # return_to, a link's target, the booking page, the link-preview fallback image.
7
+ #
8
+ # A local path begins with exactly one "/" and carries no control character
9
+ # and no backslash. Each clause closes a way a browser leaves the site:
10
+ # "https://x", "javascript:x" - no leading "/": a scheme or a relative path
11
+ # "//x" - protocol-relative: another host
12
+ # "/\x", "/\/x" - browsers (the WHATWG URL parser) read "\" as
13
+ # "/", so these are "//x" in disguise
14
+ # "/\tx", "/\nx" - the URL parser strips tab and newline, so a
15
+ # control character can hide a second "/"
16
+ # Pure Ruby, so it loads and unit-tests without Rails.
17
+ module LocalPath
18
+ # A control character (C0 or DEL) or a backslash anywhere in the path.
19
+ UNSAFE_CHARACTER = /[\x00-\x1f\x7f\\]/
20
+
21
+ module_function
22
+
23
+ def local?(path)
24
+ string = path.to_s
25
+ string.start_with?("/") && !string.start_with?("//") && !string.match?(UNSAFE_CHARACTER)
26
+ end
27
+ end
28
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.91.1"
2
+ VERSION = "0.92.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -12,6 +12,8 @@ require "active_support/core_ext/module/attribute_accessors"
12
12
  require "active_support/core_ext/numeric/time"
13
13
  require "active_support/core_ext/integer/time"
14
14
  require "studio/version"
15
+ require "studio/local_path"
16
+ require "studio/component_gallery"
15
17
  require "studio/log_rotation"
16
18
  require "studio/ip_locations"
17
19
  require "studio/geo"
@@ -689,6 +691,22 @@ module Studio
689
691
  # config.draw_booking_routes = true
690
692
  mattr_accessor :draw_booking_routes, default: false
691
693
 
694
+ # Draw the component gallery (Lookbook at /admin/style/components) in
695
+ # PRODUCTION. An app whose bundle loads lookbook draws it in development and
696
+ # test; in production only an app that also sets this does. No app sets it
697
+ # yet; the hub is meant to, with lookbook in its production bundle. Admins
698
+ # only, 404 to anyone else. See Studio::ComponentGallery.
699
+ #
700
+ # config.lookbook_in_production = true
701
+ mattr_accessor :lookbook_in_production, default: false
702
+
703
+ # Whether this app draws the component gallery: its bundle loaded lookbook,
704
+ # and this is development or test, or production with the flag.
705
+ def self.lookbook_mounted?
706
+ ComponentGallery.mounted?(env: Rails.env, in_production: lookbook_in_production,
707
+ lookbook_loaded: ComponentGallery.lookbook_loaded?)
708
+ end
709
+
692
710
  # WHERE THE APP'S OWN BOOKING PAGE LIVES, for an app that renders
693
711
  # `studio_booking_frame` in a view of its own instead of drawing the engine's
694
712
  # /schedule: a path ("/schedule"), or a callable receiving the view
@@ -1530,6 +1548,19 @@ module Studio
1530
1548
  get "admin/style", to: "style#index", as: :admin_style
1531
1549
  get "admin/design_system", to: redirect("/admin/style"), as: :admin_design_system
1532
1550
 
1551
+ # The component gallery: Lookbook over the engine's ViewComponent previews.
1552
+ # The router refuses anyone but a signed-in admin, so a visitor and a
1553
+ # non-admin get 404 (Studio::ComponentGallery::AdminConstraint). Drawn
1554
+ # only where the host's bundle loaded lookbook: in development and test,
1555
+ # and in production only where Studio.lookbook_in_production is set.
1556
+ if Studio.lookbook_mounted?
1557
+ constraints(Studio::ComponentGallery::AdminConstraint.new) do
1558
+ mount Lookbook::Engine, at: Studio::ComponentGallery::MOUNT_PATH, as: :studio_component_gallery
1559
+ mount Rack::Files.new(Studio::ComponentGallery.lookbook_assets_root),
1560
+ at: Studio::ComponentGallery::ASSETS_PATH, as: :studio_component_gallery_assets
1561
+ end
1562
+ end
1563
+
1533
1564
  # The standard transactional-email page. Canonical at /admin/emails
1534
1565
  # (Studio::EmailsController): index lists every registered email with its
1535
1566
  # 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,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.91.1
4
+ version: 0.92.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -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
@@ -600,6 +650,7 @@ files:
600
650
  - lib/studio/booking.rb
601
651
  - lib/studio/cable.rb
602
652
  - lib/studio/color_scale.rb
653
+ - lib/studio/component_gallery.rb
603
654
  - lib/studio/email.rb
604
655
  - lib/studio/email_smoke.rb
605
656
  - lib/studio/engine.rb
@@ -614,6 +665,7 @@ files:
614
665
  - lib/studio/link_preview.rb
615
666
  - lib/studio/link_resolution.rb
616
667
  - lib/studio/link_token.rb
668
+ - lib/studio/local_path.rb
617
669
  - lib/studio/log_rotation.rb
618
670
  - lib/studio/mail_transport.rb
619
671
  - lib/studio/name_parts.rb