studio-engine 0.130.0 → 0.131.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +151 -0
  3. data/README.md +43 -6
  4. data/app/assets/tailwind/studio_engine/engine-motion.css +174 -0
  5. data/app/assets/tailwind/studio_engine/engine.css +587 -0
  6. data/app/assets/tailwind/studio_engine/theme.css +4 -0
  7. data/app/controllers/concerns/studio/error_handling.rb +53 -15
  8. data/app/controllers/concerns/studio/magic_link_issuing.rb +4 -0
  9. data/app/controllers/magic_links_controller.rb +25 -2
  10. data/app/controllers/registrations_controller.rb +58 -22
  11. data/app/controllers/style_controller.rb +6 -0
  12. data/app/helpers/studio/agent_chat_helper.rb +14 -1
  13. data/app/helpers/studio/crumbs_helper.rb +34 -0
  14. data/app/helpers/studio/loader_helper.rb +79 -2
  15. data/app/javascript/studio/agent_chat.js +294 -6
  16. data/app/javascript/studio/carousel.js +99 -0
  17. data/app/javascript/studio/controllers/carousel_controller.js +138 -0
  18. data/app/javascript/studio/controllers/infinite_scroll_controller.js +17 -0
  19. data/app/javascript/studio/controllers/pull_to_refresh_controller.js +18 -0
  20. data/app/javascript/studio/controllers/talk_to_agent_controller.js +20 -0
  21. data/app/javascript/studio/infinite_scroll.js +142 -0
  22. data/app/javascript/studio/loader.js +6 -1
  23. data/app/javascript/studio/page_transitions.js +65 -8
  24. data/app/javascript/studio/pull_to_refresh.js +147 -0
  25. data/app/javascript/studio/stimulus.js +12 -1
  26. data/app/javascript/studio/talk_to_agent.js +116 -0
  27. data/app/views/components/_geo_signpost.html.erb +2 -2
  28. data/app/views/components/_link_sidebar.html.erb +2 -2
  29. data/app/views/components/_link_sidebar_trigger.html.erb +1 -1
  30. data/app/views/components/_sidebar_panel.html.erb +1 -1
  31. data/app/views/error_logs/index.html.erb +2 -8
  32. data/app/views/studio/_carousel.html.erb +97 -0
  33. data/app/views/studio/_page_header.html.erb +52 -11
  34. data/app/views/studio/agent_chat/_chat.html.erb +7 -4
  35. data/app/views/studio/agent_chat/_talk_modal.html.erb +58 -0
  36. data/app/views/studio/agent_chat/_turn.html.erb +1 -1
  37. data/app/views/studio/loader/_infinite_scroll.html.erb +42 -0
  38. data/app/views/studio/loader/_loader.html.erb +20 -3
  39. data/app/views/studio/loader/_pull_to_refresh.html.erb +20 -0
  40. data/app/views/studio/loader/_skeleton.html.erb +37 -0
  41. data/app/views/style/_loaders.html.erb +118 -0
  42. data/app/views/style/_loaders_feed_page.html.erb +16 -0
  43. data/app/views/style/_modals.html.erb +12 -0
  44. data/app/views/style/_specimen.html.erb +17 -10
  45. data/app/views/style/_tricks.html.erb +64 -2
  46. data/app/views/style/index.html.erb +14 -8
  47. data/app/views/style/loaders_feed.html.erb +7 -0
  48. data/lib/studio/crumbs.rb +150 -0
  49. data/lib/studio/loader.rb +30 -2
  50. data/lib/studio/version.rb +1 -1
  51. data/lib/studio.rb +3 -0
  52. metadata +20 -2
@@ -0,0 +1,118 @@
1
+ <%# Loaders section of the living style guide (admin/style).
2
+
3
+ The eight ways a page says "wait", one specimen each, in the order of the
4
+ reel Alex McRitchie shared (task engine-loader-family): spinner, skeleton,
5
+ progress bar, pulsing dots, button loader, full-screen loader, pull to
6
+ refresh, infinite scroll. Every specimen is the engine's own helper
7
+ (Studio::LoaderHelper, docs/LOADER.md), so what is on this page is what a
8
+ view gets. The branded styles themselves are under Tricks.
9
+
10
+ The infinite-scroll specimen is live: its pages come from feed_path, the
11
+ admin-only demo feed (StyleController#loaders_feed) unless a caller names
12
+ another. Pull to refresh is drawn still: it arms only on a phone with touch,
13
+ at the top of a page. %>
14
+ <%
15
+ # A view rendered without the app's routes (a test's bare ActionView) gets the
16
+ # path Studio.routes draws.
17
+ feed_path = local_assigns[:feed_path] ||
18
+ (respond_to?(:admin_style_loaders_feed_path) ? admin_style_loaders_feed_path : "/admin/style/loaders_feed")
19
+ erb = ->(code) { "<" + "%= " + code + " %" + ">" }
20
+ erb_end = "<" + "% end %" + ">"
21
+ %>
22
+ <section id="loaders" class="section-target space-y-6" data-style-loaders>
23
+ <%= render "studio/section_header", title: "Loaders" %>
24
+ <p class="text-muted text-sm max-w-2xl">Eight ways to say wait. Match the loader to the wait.</p>
25
+
26
+ <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
27
+ <%= render layout: "style/specimen",
28
+ locals: { klass: "studio_loader style: :spinner",
29
+ note: "A small ring for a short wait beside text or in a control.",
30
+ usage: erb.call("studio_loader style: :spinner") } do %>
31
+ <div data-loader-specimen="spinner" class="flex items-center gap-4">
32
+ <%= studio_loader style: :spinner %>
33
+ <%= studio_loader style: :spinner, size: 32 %>
34
+ <span class="inline-flex items-center gap-2 text-sm text-body"><%= studio_loader style: :spinner, size: 16, label: "Checking" %>Checking</span>
35
+ </div>
36
+ <% end %>
37
+
38
+ <%= render layout: "style/specimen",
39
+ locals: { klass: "studio_skeleton :avatar / :card / :lines / :media",
40
+ note: "Grey shapes of the content to come, so the page keeps its layout.",
41
+ usage: [erb.call("studio_skeleton :avatar, count: 3"), erb.call('studio_skeleton :card, count: 3, class: "sm:grid-cols-3"')].join("\n") } do %>
42
+ <div data-loader-specimen="skeleton" class="grid w-full gap-4">
43
+ <%= studio_skeleton :avatar, count: 2, label: "Loading people" %>
44
+ <%= studio_skeleton :lines, lines: 2, label: "Loading notes" %>
45
+ </div>
46
+ <% end %>
47
+
48
+ <%= render layout: "style/specimen",
49
+ locals: { klass: "studio_loader style: :bar, percent:",
50
+ note: "A bar for a wait you can measure; with no percent it sweeps.",
51
+ usage: [erb.call("studio_loader style: :bar, percent: 62"), erb.call("studio_loader style: :bar"), 'StudioLoader.setPercent(el, 80)'].join("\n") } do %>
52
+ <div data-loader-specimen="bar" class="grid w-full gap-4">
53
+ <%= studio_loader style: :bar, percent: 62, label: "Uploading" %>
54
+ <%= studio_loader style: :bar, label: "Importing" %>
55
+ </div>
56
+ <% end %>
57
+
58
+ <%= render layout: "style/specimen",
59
+ locals: { klass: "studio_loader style: :dots",
60
+ note: "Three dots for a reply on its way, such as a chat answer.",
61
+ usage: erb.call("studio_loader style: :dots") } do %>
62
+ <div data-loader-specimen="dots" class="flex items-center gap-6">
63
+ <%= studio_loader style: :dots %>
64
+ <span class="inline-flex items-center gap-2 rounded-2xl bg-inset px-3 py-2 text-sm text-body"><%= studio_loader style: :dots, size: 24, label: "Alex is typing" %></span>
65
+ </div>
66
+ <% end %>
67
+
68
+ <%= render layout: "style/specimen",
69
+ locals: { klass: "studio_loader_pending (inside a button)",
70
+ note: "The button keeps its place and shows that it is working.",
71
+ usage: ["<button class=\"btn btn-primary\">", " <span data-loader-idle>Save</span>", " #{erb.call('studio_loader_pending style: :spinner')}", " <span data-loader-busy>Saving…</span>", "</button>"].join("\n") } do %>
72
+ <div data-loader-specimen="button" class="flex flex-wrap items-center gap-3" x-data="{ busy: false }">
73
+ <button type="button" class="btn btn-primary" x-bind:aria-busy="busy ? 'true' : 'false'" aria-busy="false"
74
+ x-on:click="busy = true; setTimeout(() => busy = false, 2000)" data-test="style-loader-save">
75
+ <span data-loader-idle>Save</span>
76
+ <%= studio_loader_pending style: :spinner %><span data-loader-busy>Saving…</span>
77
+ </button>
78
+ <button type="button" class="btn btn-secondary" aria-busy="true" tabindex="-1">
79
+ <%= studio_loader_pending %><span class="ml-2">Saving…</span>
80
+ </button>
81
+ </div>
82
+ <% end %>
83
+
84
+ <%= render layout: "style/specimen",
85
+ locals: { klass: "studio_loader_overlay (full screen)",
86
+ note: "The branded overlay, for a page that takes longer than 350ms.",
87
+ usage: ['window.StudioLoader.show("Caption")', "window.StudioLoader.hide()"].join("\n") } do %>
88
+ <div data-loader-specimen="overlay">
89
+ <button type="button" class="btn btn-secondary" data-test="style-loaders-overlay"
90
+ x-data x-on:click="window.StudioLoader && window.StudioLoader.show('Loading your page…'); setTimeout(() => window.StudioLoader && window.StudioLoader.hide(), 2000)">
91
+ Show it for 2 seconds
92
+ </button>
93
+ </div>
94
+ <% end %>
95
+
96
+ <%= render layout: "style/specimen",
97
+ locals: { klass: "studio_pull_to_refresh (phones only)",
98
+ note: "On a phone, pull down from the top of the page to reload it.",
99
+ usage: [erb.call('studio_pull_to_refresh frame: "feed" do'), " ...", erb_end].join("\n") } do %>
100
+ <%# Drawn still, mid-refresh: this wrapper has no controller, so it never arms. %>
101
+ <div data-loader-specimen="pull" class="studio-pull-refresh w-40 h-40 overflow-hidden rounded-2xl border border-subtle bg-surface pt-20 px-3" data-pull-state="refreshing">
102
+ <span class="studio-pull-refresh-indicator" aria-hidden="true">
103
+ <span class="spinner studio-pull-refresh-spinner" style="--spinner-size: 1.25rem; --spinner-color: currentColor; --spinner-track: color-mix(in srgb, currentColor 22%, transparent)"></span>
104
+ </span>
105
+ <%= studio_skeleton :lines, lines: 3, label: "" %>
106
+ </div>
107
+ <% end %>
108
+
109
+ <%= render layout: "style/specimen",
110
+ locals: { klass: "studio_infinite_scroll id:, page:, next_url:",
111
+ note: "The next page loads as the list's end comes near, and Load more stays.",
112
+ usage: [erb.call('studio_infinite_scroll id: "posts", page: @page, next_url: @next_url do'), " ...one page of items...", erb_end].join("\n") } do %>
113
+ <div data-loader-specimen="infinite" class="w-full">
114
+ <%= render "style/loaders_feed_page", page: 1, feed_path: feed_path %>
115
+ </div>
116
+ <% end %>
117
+ </div>
118
+ </section>
@@ -0,0 +1,16 @@
1
+ <%# locals: (page:, feed_path:) %>
2
+ <%# One page of the Loaders section's live infinite-scroll specimen: four demo
3
+ items a page, three pages. The section renders page 1; the demo feed renders
4
+ the rest (StyleController#loaders_feed), and Turbo lifts each one into the
5
+ frame the page before left for it. %>
6
+ <%
7
+ pages = Studio::Loader::DEMO_FEED_PAGES
8
+ per_page = Studio::Loader::DEMO_FEED_PER_PAGE
9
+ next_url = page < pages ? "#{feed_path}?page=#{page + 1}" : nil
10
+ %>
11
+ <%= studio_infinite_scroll id: "style-loaders-feed", page: page, next_url: next_url,
12
+ announce: "%{count} more items loaded", class: "grid grid-cols-2 gap-2" do %>
13
+ <% ((page - 1) * per_page + 1).upto(page * per_page) do |n| %>
14
+ <div class="rounded-lg bg-inset px-3 py-2 text-sm text-body" data-feed-item>Item <%= n %></div>
15
+ <% end %>
16
+ <% end %>
@@ -1167,6 +1167,18 @@
1167
1167
  </div>
1168
1168
  </section>
1169
1169
 
1170
+ <%# ===================================================================== %>
1171
+ <%# 6b. TALK TO AN AGENT (studio/agent_chat/_talk_modal). No live specimen:
1172
+ the card holds the app's real agent chat, which needs a soul on
1173
+ Studio.agent_chat_souls and the hub behind it (docs/AGENT_CHAT.md). %>
1174
+ <%# ===================================================================== %>
1175
+ <section id="modals-agent-chat" class="space-y-5">
1176
+ <div class="space-y-1">
1177
+ <h3 class="type-h4 text-heading">Talk to an agent</h3>
1178
+ <p class="text-muted text-sm" data-test="style-talk-to-agent">Render <code class="font-mono text-2xs">studio/agent_chat/talk_modal</code> once and mark any link with <code class="font-mono text-2xs">studio_talk_to_agent_link</code>: a click opens the agent's chat in a card, and the agent starts answering the link's opener as the card opens.</p>
1179
+ </div>
1180
+ </section>
1181
+
1170
1182
  <%# ===================================================================== %>
1171
1183
  <%# 7. REWARDS / leveling (gated by Studio.feature?(:leveling)) %>
1172
1184
  <%# ===================================================================== %>
@@ -1,7 +1,8 @@
1
1
  <%# Specimen card for the living style guide (admin/style).
2
2
  Reusable chrome around ONE primitive, framed for copy-paste-for-an-agent:
3
3
  stage - the rendered primitive (yielded from the caller block)
4
- meta - its class name (prominent + one-click copy) and a copyable usage snippet
4
+ meta - its class name and a copyable usage snippet; one Copy per card (the
5
+ snippet's, else the class name's)
5
6
  locals:
6
7
  klass (String, required) - the class label, shown prominently with a
7
8
  copy button so an agent can grab the exact class list.
@@ -42,20 +43,26 @@
42
43
  <% if local_assigns[:note].present? %>
43
44
  <p class="text-sm text-body"><%= local_assigns[:note] %></p>
44
45
  <% end %>
45
- <%# Class name — prominent, with a one-click copy so an agent can grab it. %>
46
+ <%# ONE Copy per card. With a usage snippet, the snippet's Copy is the one: it holds
47
+ the class too. Without one, the class name carries the Copy. %>
48
+ <% snippet = local_assigns[:usage] %>
46
49
  <div class="flex items-start justify-between gap-2" x-data="{ copied: false }">
47
50
  <code x-ref="klass" class="font-mono text-xs font-bold text-heading break-all leading-relaxed flex-1 min-w-0"><%= local_assigns[:klass] %></code>
48
- <button type="button"
49
- class="btn btn-neutral btn-sm shrink-0"
50
- aria-label="Copy class name"
51
- @click="navigator.clipboard?.writeText($refs.klass.innerText); copied = true; setTimeout(() => copied = false, 1200)">
52
- <span x-text="copied ? 'Copied' : 'Copy'">Copy</span>
53
- </button>
51
+ <% unless snippet.present? %>
52
+ <button type="button"
53
+ class="btn btn-neutral btn-sm shrink-0"
54
+ aria-label="Copy class name"
55
+ @click="navigator.clipboard?.writeText($refs.klass.innerText); copied = true; setTimeout(() => copied = false, 1200)">
56
+ <span x-text="copied ? 'Copied' : 'Copy'">Copy</span>
57
+ </button>
58
+ <% end %>
54
59
  </div>
55
- <% snippet = local_assigns[:usage] %>
56
60
  <% if snippet.present? %>
61
+ <%# The code wraps inside its box, and pr-24 keeps every line clear of the Copy
62
+ button, "Copied" included: 74px with its inset on macOS, 3px wider in CI's
63
+ Linux fonts, so pr-20 was not enough. %>
57
64
  <div class="relative" x-data="{ copied: false }">
58
- <pre class="bg-inset text-body text-2xs rounded-lg px-3 py-2 pr-16 overflow-x-auto"><code x-ref="snippet"><%= snippet %></code></pre>
65
+ <pre class="bg-inset text-body text-2xs rounded-lg px-3 py-2 pr-24 whitespace-pre-wrap break-words"><code x-ref="snippet"><%= snippet %></code></pre>
59
66
  <button type="button"
60
67
  class="btn btn-neutral btn-sm absolute top-2 right-2"
61
68
  aria-label="Copy usage snippet"
@@ -85,6 +85,13 @@
85
85
  <button type="button" class="btn btn-primary studio-border-glow">Save</button>
86
86
  <% end %>
87
87
 
88
+ <%= render layout: "style/specimen",
89
+ locals: { klass: ".studio-border-glow--clear",
90
+ note: "The same rainbow ring and halo on a clear button.",
91
+ usage: %(<button class="btn studio-border-glow studio-border-glow--clear">Save</button>) } do %>
92
+ <button type="button" class="btn studio-border-glow studio-border-glow--clear" data-border-glow-clear>Save</button>
93
+ <% end %>
94
+
88
95
  <%= render layout: "style/specimen",
89
96
  locals: { klass: ".sheen .sheen--loop",
90
97
  note: "Adds a band of light that sweeps across the button.",
@@ -168,7 +175,7 @@
168
175
  <section class="space-y-5" data-page-header-specimen>
169
176
  <div class="space-y-1">
170
177
  <h3 class="type-h4 text-heading">Page header</h3>
171
- <p class="text-muted text-sm"><code class="font-mono text-2xs">studio/_page_header</code> is the title row, with optional meta and tabs.</p>
178
+ <p class="text-muted text-sm"><code class="font-mono text-2xs">studio/_page_header</code>: crumbs above the title, tabs on the right, other links under it; the open pill glides.</p>
172
179
  </div>
173
180
  <div class="grid gap-4 lg:grid-cols-2">
174
181
  <%# The classes alone, not the partial: the partial's row is sticky, joins the
@@ -176,8 +183,18 @@
176
183
  <% [["At rest", {}], ["Stuck", { stuck: "" }]].each do |state, data| %>
177
184
  <%= render layout: "style/specimen",
178
185
  locals: { klass: "studio/_page_header (#{state.downcase})",
179
- usage: %(<%= render "studio/page_header",\n title: "Models", meta: "v1.2.3", label: "Models",\n tabs: [{ label: "Users", href: users_path, current: "page" }, { label: "Teams", href: teams_path }] %\>) } do %>
186
+ usage: %(<%= render "studio/page_header",\n title: "Models", meta: "v1.2.3", label: "Models",\n crumbs: studio_crumbs(parent: :admin),\n tabs: [{ label: "Users", href: users_path, current: "page" }, { label: "Teams", href: teams_path }],\n links: [{ label: "Sessions", href: sessions_path }] %\>) } do %>
180
187
  <%= tag.div(class: "page-header w-full px-4", data: data) do %>
188
+ <div class="page-header-trail">
189
+ <%# A div, not the partial's <nav>: a specimen adds no landmark to the page. %>
190
+ <div class="page-header-crumbs">
191
+ <ol>
192
+ <li><a href="#tricks" class="page-header-crumb">Admin</a></li>
193
+ <li><span class="page-header-crumb-current">Models</span></li>
194
+ </ol>
195
+ </div>
196
+ <a href="#tricks" class="page-header-parent"><span aria-hidden="true">&lsaquo;</span> Admin</a>
197
+ </div>
181
198
  <div class="page-header-lead">
182
199
  <p class="page-header-title">Models</p>
183
200
  <p class="page-header-meta label-upper">v1.2.3</p>
@@ -188,6 +205,10 @@
188
205
  <a href="#tricks" class="btn btn-sm btn-neutral">Teams</a>
189
206
  </div>
190
207
  </div>
208
+ <div class="page-header-links">
209
+ <a href="#tricks" class="page-header-link">Sessions</a>
210
+ <a href="#tricks" class="page-header-link">Invites</a>
211
+ </div>
191
212
  <% end %>
192
213
  <% end %>
193
214
  <% end %>
@@ -217,6 +238,47 @@
217
238
  </div>
218
239
  </section>
219
240
 
241
+ <!-- Carousel -->
242
+ <section class="space-y-5" data-carousel-specimen>
243
+ <div class="space-y-1">
244
+ <h3 class="type-h4 text-heading">Carousel</h3>
245
+ <p class="text-muted text-sm"><code class="font-mono text-2xs">studio/_carousel</code> is a swipeable row of any slides, with arrows, dots and arrow keys.</p>
246
+ </div>
247
+ <%# The live partial: a carousel joins no stack and holds no title, so the
248
+ specimen is the real thing. Swipe it, press its arrows, or focus it and
249
+ use the arrow keys. %>
250
+ <%= render layout: "style/specimen",
251
+ locals: { klass: "studio/_carousel",
252
+ note: "Shows a set too long to grid, a few slides at a time.",
253
+ usage: %(<%= render layout: "studio/carousel",\n locals: { label: "Portfolio", items: @apps, per_view: 3 } do |app| %\>\n <%= render "apps/card", app: app %\>\n<% end %\>) } do %>
254
+ <div class="w-full min-w-0">
255
+ <%= render layout: "studio/carousel",
256
+ locals: { label: "Specimen slides", items: (1..7).to_a, per_view: 3, id: "style-carousel-specimen" } do |n| %>
257
+ <div class="card h-full p-5 space-y-1">
258
+ <p class="label-upper">Slide <%= n %></p>
259
+ <p class="text-sm text-body">Any block content.</p>
260
+ </div>
261
+ <% end %>
262
+ </div>
263
+ <% end %>
264
+
265
+ <%= render layout: "style/specimen",
266
+ locals: { klass: "studio/_carousel (style: :spotlight)",
267
+ note: "Centres one slide at full size and colour, with its caption, and shrinks, fades and greys its neighbours as they scroll by.",
268
+ usage: %(<%= render layout: "studio/carousel",\n locals: { label: "Team", items: @people, style: :spotlight,\n caption: ->(person) { person.title } } do |person| %\>\n <%= render "people/card", person: person %\>\n<% end %\>) } do %>
269
+ <div class="w-full min-w-0" data-carousel-spotlight-specimen>
270
+ <%= render layout: "studio/carousel",
271
+ locals: { label: "Spotlight specimen", items: (1..7).to_a, per_view: 3, style: :spotlight,
272
+ id: "style-carousel-spotlight-specimen", caption: ->(n) { "Slide #{n}" } } do |n| %>
273
+ <div class="card h-full p-5 space-y-1 aspect-[4/3]" style="background-image: linear-gradient(150deg, rgb(var(--color-primary-rgb) / 0.35), transparent)">
274
+ <p class="label-upper">Slide <%= n %></p>
275
+ <p class="text-sm text-body">Any block content.</p>
276
+ </div>
277
+ <% end %>
278
+ </div>
279
+ <% end %>
280
+ </section>
281
+
220
282
  <!-- Surfaces, text and form -->
221
283
  <section class="space-y-5">
222
284
  <div class="space-y-1">
@@ -3,19 +3,21 @@
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
- Five ENGINE sections, reached by the page header below and rendered
6
+ Six ENGINE sections, reached by the page header 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 theme editor, the live preview, and the
9
- logos and type), then Modals (the shared host + engine card blocks), Tricks
10
- (the button/surface/motion/effect/leveling primitives, framed as a board of
11
- copy-paste-for-an-agent snippets), Tasks (the shared board primitive), and
9
+ logos and type), then Tricks (the button/surface/motion/effect/leveling
10
+ primitives, framed as a board of copy-paste-for-an-agent snippets; second by
11
+ Alex McRitchie's ruling of 2026-11-05), Loaders (the eight loading primitives,
12
+ docs/LOADER.md), Modals (the shared host + engine card blocks), Tasks (the
13
+ shared board primitive), and
12
14
  Components (the ViewComponent previews, linking into the gallery at
13
15
  /admin/style/components).
14
16
 
15
- A SIXTH section is the host app's own, and appears only on an app that asks
17
+ A SEVENTH section is the host app's own, and appears only on an app that asks
16
18
  for it: define app/views/style/host/_modals.html.erb in the consuming app
17
19
  and the guide grows a section (and a nav pill) for that app's own modals,
18
- between Modals and Tricks. The gem still renders the section chrome; the app
20
+ between Modals and Tasks. The gem still renders the section chrome; the app
19
21
  supplies the specimens. Full contract, including which modal store a host
20
22
  specimen drives and why it is not the one the Modals section uses:
21
23
  app/views/style/_host.html.erb.
@@ -44,8 +46,11 @@
44
46
  # The section chrome and the consumer contract live in style/_host.html.erb.
45
47
  host_modals = lookup_context.exists?("modals", ["style/host"], true)
46
48
 
49
+ # Tricks second, after the landing Theme (Alex McRitchie, 2026-11-05).
47
50
  sections = [
48
51
  ["theme", "Theme"],
52
+ ["tricks", "Tricks"],
53
+ ["loaders", "Loaders"],
49
54
  ["modals", "Modals"]
50
55
  ]
51
56
  # Adjacent to Modals, not appended: this page groups by SUBJECT, and a host's
@@ -53,7 +58,6 @@
53
58
  # name, so the pill says whose section it is.
54
59
  sections << ["host-modals", Studio.app_name] if host_modals
55
60
  sections += [
56
- ["tricks", "Tricks"],
57
61
  ["tasks", "Tasks"],
58
62
  ["components", "Components"]
59
63
  ]
@@ -68,6 +72,7 @@
68
72
  right before the module loads. %>
69
73
  <%= render "studio/page_header",
70
74
  title: "Style",
75
+ crumbs: studio_crumbs_here,
71
76
  meta: "v#{Studio::VERSION}",
72
77
  meta_title: "studio-engine",
73
78
  label: "Style guide sections",
@@ -83,11 +88,12 @@
83
88
  (76px on the hub at phone width). %>
84
89
  <div class="space-y-24 pt-10">
85
90
  <%= render "style/theme" %>
91
+ <%= render "style/tricks" %>
92
+ <%= render "style/loaders" %>
86
93
  <%= render "style/modals" %>
87
94
  <% if host_modals %>
88
95
  <%= render "style/host" %>
89
96
  <% end %>
90
- <%= render "style/tricks" %>
91
97
  <%= render "style/tasks" %>
92
98
  <%= render "style/components" %>
93
99
  </div>
@@ -0,0 +1,7 @@
1
+ <%# The Loaders section's demo feed, one page (StyleController#loaders_feed). A
2
+ whole page, so "Load more" opened with no script lands somewhere real; a
3
+ frame request takes just the frame it asked for out of it. %>
4
+ <div class="max-w-xl mx-auto px-4 py-10 space-y-4">
5
+ <h1 class="type-h3 text-heading">Loaders demo feed</h1>
6
+ <%= render "style/loaders_feed_page", page: @page, feed_path: admin_style_loaders_feed_path %>
7
+ </div>
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # THE PARENT MAP behind studio/_page_header's crumb trail (docs/PAGE_HEADER.md, "Crumbs").
5
+ # An app names each page that has children once, with its label, its address and its own
6
+ # parent. A page then names only its parent, and the trail builds itself up the chain.
7
+ #
8
+ # # config/initializers/studio_crumbs.rb
9
+ # Studio.crumbs.draw do
10
+ # page :admin, "Admin", :admin_dashboard_path
11
+ # page :deployments, "Deployments", :deployments_path, parent: :admin
12
+ # page :release, ->(release:) { release.slug }, ->(release:) { release_path(release) },
13
+ # parent: :deployments
14
+ # end
15
+ #
16
+ # <%= render "studio/page_header", title: "Deployments", crumbs: studio_crumbs(:deployments) %>
17
+ # <%= render "studio/page_header", title: @task.title, crumbs: studio_crumbs(parent: :deployments) %>
18
+ #
19
+ # A label or an address is a String, a Symbol (a route helper, read in the view, then on
20
+ # main_app) or a lambda the view runs with the page's own keywords.
21
+ class Crumbs
22
+ Page = Struct.new(:key, :label, :path, :parent, keyword_init: true)
23
+
24
+ class UnknownPage < KeyError; end
25
+ class Cycle < ArgumentError; end
26
+
27
+ def initialize
28
+ @pages = {}
29
+ end
30
+
31
+ def draw(&block)
32
+ instance_eval(&block)
33
+ self
34
+ end
35
+
36
+ def page(key, label, path, parent: nil)
37
+ @pages[key.to_sym] = Page.new(key: key.to_sym, label: label, path: path, parent: parent&.to_sym)
38
+ end
39
+
40
+ def clear
41
+ @pages.clear
42
+ self
43
+ end
44
+
45
+ def key?(key) = @pages.key?(key.to_sym)
46
+
47
+ def fetch(key)
48
+ @pages.fetch(key.to_sym) { raise UnknownPage, "Studio.crumbs has no page #{key.inspect}" }
49
+ end
50
+
51
+ # The keys from the root down to `key`, `key` included.
52
+ def chain(key)
53
+ keys = []
54
+ current = key&.to_sym
55
+ while current
56
+ raise Cycle, "Studio.crumbs loops at #{current.inspect}" if keys.include?(current)
57
+
58
+ keys.unshift(current)
59
+ current = fetch(current).parent
60
+ end
61
+ keys
62
+ end
63
+
64
+ # The trail for one page: its ancestors, root first, as [{ label:, href: }]. Name the page
65
+ # itself (`key`, the page in the map) or only its parent (`parent:`, a page not in the map).
66
+ def trail(view, key = nil, parent: nil, **locals)
67
+ raise ArgumentError, "name the page or its parent, not both" if key && parent
68
+
69
+ keys = key ? chain(key)[0...-1] : chain(parent)
70
+ keys.map do |name|
71
+ page = fetch(name)
72
+ { label: resolve(view, page.label, locals).to_s, href: resolve(view, page.path, locals).to_s }
73
+ end
74
+ end
75
+
76
+ # The map's page at `path`, or nil: for a page the engine draws (the style guide, the
77
+ # model pages), which cannot know the host's key for it. Only pages whose address
78
+ # needs no keywords can match.
79
+ def key_for_path(view, path)
80
+ wanted = normalize(path)
81
+ @pages.each_value do |page|
82
+ next if page.path.is_a?(Proc) && keywords(page.path, required: true).any?
83
+
84
+ address = begin
85
+ resolve(view, page.path, {}).to_s
86
+ rescue StandardError
87
+ next
88
+ end
89
+ return page.key if normalize(address) == wanted
90
+ end
91
+ nil
92
+ end
93
+
94
+ # A name the map lacks, or a loop in it, is a mistake in the app's own code. In
95
+ # development and test it raises, so the mistake shows at once. Anywhere else (QA
96
+ # dynos run as production, so not Rails.env.production?) the page renders with no
97
+ # trail and the error is logged: a missing crumb is not worth a 500.
98
+ def self.strict?(env = (::Rails.env if defined?(::Rails) && ::Rails.respond_to?(:env)))
99
+ return true if env.nil?
100
+
101
+ env.development? || env.test?
102
+ end
103
+
104
+ REPORTED = {}
105
+ REPORTED_LOCK = Mutex.new
106
+
107
+ # Once per message per process: a layout renders on every request.
108
+ def self.report(error)
109
+ first = REPORTED_LOCK.synchronize { REPORTED[error.message] ? false : (REPORTED[error.message] = true) }
110
+ return unless first
111
+
112
+ if defined?(::ErrorLog) && ::ErrorLog.respond_to?(:capture!)
113
+ ::ErrorLog.capture!(error)
114
+ elsif defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
115
+ ::Rails.logger.error("[studio-crumbs] no trail drawn: #{error.class}: #{error.message}")
116
+ end
117
+ rescue StandardError
118
+ nil # reporting is best-effort: the page still renders
119
+ end
120
+
121
+ private
122
+
123
+ # An address with no trailing slash; the root stays "/".
124
+ def normalize(path) = path.to_s.chomp("/").presence || "/"
125
+
126
+ def resolve(view, value, locals)
127
+ case value
128
+ when Proc then view.instance_exec(**locals.slice(*keywords(value)), &value)
129
+ when Symbol then route(view, value)
130
+ else value
131
+ end
132
+ end
133
+
134
+ def keywords(proc, required: false)
135
+ kinds = required ? %i[keyreq] : %i[key keyreq]
136
+ proc.parameters.filter_map { |kind, name| name if kinds.include?(kind) }
137
+ end
138
+
139
+ def route(view, name)
140
+ return view.public_send(name) if view.respond_to?(name)
141
+ return view.main_app.public_send(name) if view.respond_to?(:main_app)
142
+
143
+ raise NoMethodError, "Studio.crumbs: no route helper #{name}"
144
+ end
145
+ end
146
+
147
+ def self.crumbs
148
+ @crumbs ||= Crumbs.new
149
+ end
150
+ end
data/lib/studio/loader.rb CHANGED
@@ -23,6 +23,11 @@
23
23
  # The icon is drawn underneath as an image; the art is inlined over it. An art
24
24
  # file that is missing, outside public/, or has no marked layer falls back to
25
25
  # breathe, so a typo can never blank the loader.
26
+ #
27
+ # PLAIN styles draw no icon, only the theme's colours: spinner, dots and bar.
28
+ # They are the small, everyday members of the same loader family, for a view to
29
+ # name with studio_loader(style:). They are not an app's brand, so the app's own
30
+ # config.loader refuses them.
26
31
  module Studio
27
32
  module Loader
28
33
  class InvalidConfig < ArgumentError; end
@@ -37,7 +42,20 @@ module Studio
37
42
  SETTING = "Studio.loader"
38
43
  GENERIC_STYLES = %i[breathe liquid orbit steel].freeze
39
44
  ART_STYLES = { weld_bead: "outline", sparkle: "spark" }.freeze
45
+ # The styles an app may declare as its loader: they draw its icon.
40
46
  STYLES = (GENERIC_STYLES + ART_STYLES.keys).freeze
47
+ # The styles a view may name that draw no icon (docs/LOADER.md, "The family").
48
+ PLAIN_STYLES = %i[spinner dots bar].freeze
49
+ # The size each style draws at when the caller names none: the icon styles at
50
+ # 96px, a spinner beside text, dots in a line, and a bar across its parent.
51
+ DEFAULT_SIZES = { spinner: 20, dots: 32, bar: "100%" }.freeze
52
+ DEFAULT_SIZE = 96
53
+ # The style guide's live infinite-scroll specimen: a demo feed of this many
54
+ # pages of this many items (style/_loaders_feed_page). Here, not on
55
+ # StyleController, so a view can read them without loading a controller that
56
+ # needs the host's ApplicationController.
57
+ DEMO_FEED_PAGES = 3
58
+ DEMO_FEED_PER_PAGE = 4
41
59
  DEFAULT_STYLE = :breathe
42
60
  KEYS = %i[style icon art].freeze
43
61
 
@@ -62,6 +80,10 @@ module Studio
62
80
  raise InvalidConfig, "#{SETTING} has unknown key :#{unknown.first} (takes #{KEYS.join(', ')})" if unknown.any?
63
81
 
64
82
  style = style!(config[:style] || DEFAULT_STYLE)
83
+ if PLAIN_STYLES.include?(style)
84
+ raise InvalidConfig, "#{SETTING} style :#{style} draws no icon, so it cannot be the app's loader " \
85
+ "(one of #{STYLES.map(&:inspect).join(', ')}); a view names it with studio_loader(style: :#{style})"
86
+ end
65
87
  icon = path!(config[:icon], "#{SETTING}[:icon]")
66
88
  art = path!(config[:art], "#{SETTING}[:art]")
67
89
  if ART_STYLES.key?(style) && art.nil?
@@ -72,13 +94,19 @@ module Studio
72
94
  { style: style, icon: icon, art: art }.freeze
73
95
  end
74
96
 
97
+ # A style name as a Symbol, or InvalidConfig. Every style is a name a view may
98
+ # use; normalize refuses the plain ones for the app's own loader.
75
99
  def style!(value)
76
100
  style = value.to_s.strip.tr("-", "_").to_sym
77
- return style if STYLES.include?(style)
101
+ return style if STYLES.include?(style) || PLAIN_STYLES.include?(style)
78
102
 
79
- raise InvalidConfig, "#{SETTING} style must be one of #{STYLES.map(&:inspect).join(', ')} (got #{value.inspect})"
103
+ raise InvalidConfig, "#{SETTING} style must be one of #{(STYLES + PLAIN_STYLES).map(&:inspect).join(', ')} (got #{value.inspect})"
80
104
  end
81
105
 
106
+ def plain_style?(style) = PLAIN_STYLES.include?(style)
107
+
108
+ def default_size(style) = DEFAULT_SIZES.fetch(style, DEFAULT_SIZE)
109
+
82
110
  def path!(value, label)
83
111
  return nil if value.nil?
84
112
  return value.strip if value.is_a?(String) && !value.strip.empty?
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.130.0"
2
+ VERSION = "0.131.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -77,6 +77,7 @@ require "studio/cable"
77
77
  require "studio/survey"
78
78
  require "studio/survey/breakdown"
79
79
  require "studio/survey/export"
80
+ require "studio/crumbs"
80
81
 
81
82
  module Studio
82
83
  mattr_accessor :app_name, default: "Studio"
@@ -2248,6 +2249,8 @@ module Studio
2248
2249
 
2249
2250
  # The living style guide (StyleController#index).
2250
2251
  get "admin/style", to: "style#index", as: :admin_style
2252
+ # One page of the Loaders section's demo feed (its live infinite scroll).
2253
+ get "admin/style/loaders_feed", to: "style#loaders_feed", as: :admin_style_loaders_feed
2251
2254
 
2252
2255
  # The component gallery: Lookbook over the engine's ViewComponent previews.
2253
2256
  # The router refuses anyone but a signed-in admin, so a visitor and a