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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +151 -0
- data/README.md +43 -6
- data/app/assets/tailwind/studio_engine/engine-motion.css +174 -0
- data/app/assets/tailwind/studio_engine/engine.css +587 -0
- data/app/assets/tailwind/studio_engine/theme.css +4 -0
- data/app/controllers/concerns/studio/error_handling.rb +53 -15
- data/app/controllers/concerns/studio/magic_link_issuing.rb +4 -0
- data/app/controllers/magic_links_controller.rb +25 -2
- data/app/controllers/registrations_controller.rb +58 -22
- data/app/controllers/style_controller.rb +6 -0
- data/app/helpers/studio/agent_chat_helper.rb +14 -1
- data/app/helpers/studio/crumbs_helper.rb +34 -0
- data/app/helpers/studio/loader_helper.rb +79 -2
- data/app/javascript/studio/agent_chat.js +294 -6
- data/app/javascript/studio/carousel.js +99 -0
- data/app/javascript/studio/controllers/carousel_controller.js +138 -0
- data/app/javascript/studio/controllers/infinite_scroll_controller.js +17 -0
- data/app/javascript/studio/controllers/pull_to_refresh_controller.js +18 -0
- data/app/javascript/studio/controllers/talk_to_agent_controller.js +20 -0
- data/app/javascript/studio/infinite_scroll.js +142 -0
- data/app/javascript/studio/loader.js +6 -1
- data/app/javascript/studio/page_transitions.js +65 -8
- data/app/javascript/studio/pull_to_refresh.js +147 -0
- data/app/javascript/studio/stimulus.js +12 -1
- data/app/javascript/studio/talk_to_agent.js +116 -0
- data/app/views/components/_geo_signpost.html.erb +2 -2
- data/app/views/components/_link_sidebar.html.erb +2 -2
- data/app/views/components/_link_sidebar_trigger.html.erb +1 -1
- data/app/views/components/_sidebar_panel.html.erb +1 -1
- data/app/views/error_logs/index.html.erb +2 -8
- data/app/views/studio/_carousel.html.erb +97 -0
- data/app/views/studio/_page_header.html.erb +52 -11
- data/app/views/studio/agent_chat/_chat.html.erb +7 -4
- data/app/views/studio/agent_chat/_talk_modal.html.erb +58 -0
- data/app/views/studio/agent_chat/_turn.html.erb +1 -1
- data/app/views/studio/loader/_infinite_scroll.html.erb +42 -0
- data/app/views/studio/loader/_loader.html.erb +20 -3
- data/app/views/studio/loader/_pull_to_refresh.html.erb +20 -0
- data/app/views/studio/loader/_skeleton.html.erb +37 -0
- data/app/views/style/_loaders.html.erb +118 -0
- data/app/views/style/_loaders_feed_page.html.erb +16 -0
- data/app/views/style/_modals.html.erb +12 -0
- data/app/views/style/_specimen.html.erb +17 -10
- data/app/views/style/_tricks.html.erb +64 -2
- data/app/views/style/index.html.erb +14 -8
- data/app/views/style/loaders_feed.html.erb +7 -0
- data/lib/studio/crumbs.rb +150 -0
- data/lib/studio/loader.rb +30 -2
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +3 -0
- 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
|
|
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
|
-
<%#
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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-
|
|
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
|
|
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">‹</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
|
-
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
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
|
|
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?
|
data/lib/studio/version.rb
CHANGED
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
|