studio-engine 0.129.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 +192 -0
- data/README.md +48 -6
- data/app/assets/tailwind/studio_engine/engine-motion.css +322 -56
- data/app/assets/tailwind/studio_engine/engine.css +737 -19
- 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/fizz_helper.rb +5 -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/style_section_nav_controller.js +4 -4
- 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/pinned_stack.js +112 -4
- 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/_fizz.html.erb +26 -0
- data/app/views/studio/_fizz_layer.html.erb +4 -1
- data/app/views/studio/_hold_button.html.erb +1 -2
- data/app/views/studio/_page_header.html.erb +55 -13
- data/app/views/studio/_section_header.html.erb +23 -0
- 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/_components.html.erb +3 -5
- data/app/views/style/_host.html.erb +3 -6
- data/app/views/style/_loaders.html.erb +118 -0
- data/app/views/style/_loaders_feed_page.html.erb +16 -0
- data/app/views/style/_modal_specimen.html.erb +5 -5
- data/app/views/style/_modals.html.erb +15 -5
- data/app/views/style/_site_footer.html.erb +1 -1
- data/app/views/style/_specimen.html.erb +21 -10
- data/app/views/style/_tasks.html.erb +3 -5
- data/app/views/style/_theme.html.erb +2 -2
- data/app/views/style/_tricks.html.erb +218 -217
- data/app/views/style/index.html.erb +21 -11
- 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 +22 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<%# locals: (turn:, name:) %>
|
|
2
|
-
<%# One turn of the chat (studio/agent_chat/_chat), from a hub turn {id, role, text, status}, identified by its id AND role (data-turn-key "<id>:<role>"): the hub answers one message row as a user turn and an agent turn sharing the row's id. The page renders it twice, blank, as the <template>s studio/agent_chat clones; a host may also render real turns with it. The visitor's words sit in a right-hand bubble; the agent's are plain text across the thread (as the hub's agent-reply), its name kept for a screen reader.
|
|
2
|
+
<%# One turn of the chat (studio/agent_chat/_chat), from a hub turn {id, role, text, status}, identified by its id AND role (data-turn-key "<id>:<role>"): the hub answers one message row as a user turn and an agent turn sharing the row's id. The page renders it twice, blank, as the <template>s studio/agent_chat clones; a host may also render real turns with it. The visitor's words sit in a right-hand bubble; the agent's are plain text across the thread (as the hub's agent-reply), its name kept for a screen reader. Never HTML: the script sets a visitor's words as text and builds an agent's small Markdown subset as nodes (studio/agent_chat, MARKDOWN). This partial renders a real turn's words as text. A failed turn says so. A held one (stored, no automatic answer coming) says that, and never asks for a resend. %>
|
|
3
3
|
<% agent = turn["role"].to_s == "agent" %>
|
|
4
4
|
<% status = turn["status"].to_s %>
|
|
5
5
|
<div class="flex flex-col gap-2 min-w-0" data-agent-chat-turn data-role="<%= agent ? "agent" : "user" %>"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<%# locals: (id:, page:, next_url:, more_label:, end_label:, announce:, items:, html: {}) %>
|
|
2
|
+
<%#
|
|
3
|
+
Infinite scroll (Studio::LoaderHelper#studio_infinite_scroll, docs/LOADER.md).
|
|
4
|
+
Every page renders this whole wrapper, so a page opened with no script is a
|
|
5
|
+
complete page. Page 2 and later put their items in the frame the page before
|
|
6
|
+
left for them; Turbo lifts just that frame out of the response, and the frame
|
|
7
|
+
takes no box (display: contents), so the items join the list in place.
|
|
8
|
+
|
|
9
|
+
The next page's frame holds an ordinary link. A click loads the frame; with no
|
|
10
|
+
script it opens the page. studio/infinite_scroll clicks it early, as the frame
|
|
11
|
+
nears the viewport, and says on the status line how many items came.
|
|
12
|
+
%>
|
|
13
|
+
<%
|
|
14
|
+
extra = html.dup
|
|
15
|
+
data = (extra.delete(:data) || {}).merge(studio_controller: "infinite-scroll",
|
|
16
|
+
infinite_scroll_announce: announce,
|
|
17
|
+
infinite_scroll_end: end_label)
|
|
18
|
+
classes = [ "studio-infinite-scroll", extra.delete(:class) ].compact.join(" ")
|
|
19
|
+
body = capture do
|
|
20
|
+
concat items
|
|
21
|
+
if next_url
|
|
22
|
+
concat(content_tag("turbo-frame", id: studio_infinite_scroll_frame_id(id, page + 1),
|
|
23
|
+
class: "studio-infinite-scroll-next", data: { infinite_scroll_next: "" }) do
|
|
24
|
+
tag.div(class: "studio-infinite-scroll-more") do
|
|
25
|
+
link_to(next_url, class: "btn btn-secondary", data: { infinite_scroll_more: "" }) do
|
|
26
|
+
safe_join([ tag.span(more_label), studio_loader_pending(style: :spinner, size: 16) ])
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end)
|
|
30
|
+
elsif end_label.present?
|
|
31
|
+
concat tag.p(end_label, class: "studio-infinite-scroll-end", data: { infinite_scroll_done: "" })
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
%>
|
|
35
|
+
<%= tag.div(id: id, class: classes, data: data, **extra) do %>
|
|
36
|
+
<% if page > 1 %>
|
|
37
|
+
<%= content_tag("turbo-frame", body, id: studio_infinite_scroll_frame_id(id, page)) %>
|
|
38
|
+
<% else %>
|
|
39
|
+
<%= body %>
|
|
40
|
+
<% end %>
|
|
41
|
+
<p class="sr-only" role="status" aria-live="polite" data-infinite-scroll-status></p>
|
|
42
|
+
<% end %>
|
|
@@ -7,6 +7,12 @@
|
|
|
7
7
|
|
|
8
8
|
A pending loader sits inside a button: hidden from assistive tech (the button
|
|
9
9
|
carries aria-busy) and from sight until the button or its form is busy.
|
|
10
|
+
|
|
11
|
+
The plain styles (spinner, dots, bar) draw no icon. They wrap the engine's
|
|
12
|
+
canonical motion primitives (.spinner, .loading-dots, .progress-meter), so the
|
|
13
|
+
family and those classes are one set of rules. A bar is a progressbar: its
|
|
14
|
+
label is its name, and a percent is its value; with none it sweeps and has no
|
|
15
|
+
value, which is how an indeterminate progressbar reads.
|
|
10
16
|
%>
|
|
11
17
|
<%
|
|
12
18
|
extra = html.dup
|
|
@@ -28,9 +34,14 @@
|
|
|
28
34
|
tag.img(src: icon, alt: "", class: css, draggable: "false", decoding: "async")
|
|
29
35
|
end
|
|
30
36
|
end
|
|
31
|
-
|
|
37
|
+
bar = style == :bar
|
|
38
|
+
role = pending ? nil : (bar ? "progressbar" : "status")
|
|
39
|
+
aria = if pending then { hidden: "true" }
|
|
40
|
+
elsif bar then { label: label.presence || "Loading", valuemin: 0, valuemax: 100, valuenow: (percent && (percent % 1).zero? ? percent.to_i : percent) }.compact
|
|
41
|
+
else { live: "polite" }
|
|
42
|
+
end
|
|
32
43
|
%>
|
|
33
|
-
<%= tag.span(class: classes, role:
|
|
44
|
+
<%= tag.span(class: classes, role: role, aria: aria, data: data, style: vars.join("; "), **extra) do %>
|
|
34
45
|
<% case style %>
|
|
35
46
|
<% when :breathe %>
|
|
36
47
|
<span class="studio-loader-halo" aria-hidden="true"></span>
|
|
@@ -62,6 +73,12 @@
|
|
|
62
73
|
<% end %>
|
|
63
74
|
<% end %>
|
|
64
75
|
</svg>
|
|
76
|
+
<% when :spinner %>
|
|
77
|
+
<span class="spinner" aria-hidden="true"></span>
|
|
78
|
+
<% when :dots %>
|
|
79
|
+
<span class="loading-dots" aria-hidden="true"><span></span><span></span><span></span></span>
|
|
80
|
+
<% when :bar %>
|
|
81
|
+
<span class="progress-meter<%= " progress-meter--indeterminate" if percent.nil? %>" aria-hidden="true"><span class="progress-meter-fill"></span></span>
|
|
65
82
|
<% when :sparkle %>
|
|
66
83
|
<%= draw_icon.call %>
|
|
67
84
|
<svg class="studio-loader-art" viewBox="<%= art[:view_box] %>" aria-hidden="true">
|
|
@@ -71,5 +88,5 @@
|
|
|
71
88
|
<% end %>
|
|
72
89
|
</svg>
|
|
73
90
|
<% end %>
|
|
74
|
-
<% unless pending || label.blank? %><span class="sr-only"><%= label %></span><% end %>
|
|
91
|
+
<% unless pending || bar || label.blank? %><span class="sr-only"><%= label %></span><% end %>
|
|
75
92
|
<% end %>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<%# locals: (frame:, label:, body:, html: {}) %>
|
|
2
|
+
<%#
|
|
3
|
+
Pull to refresh (Studio::LoaderHelper#studio_pull_to_refresh, docs/LOADER.md).
|
|
4
|
+
The indicator is hidden until a finger pulls; studio/pull_to_refresh arms only
|
|
5
|
+
on a phone-width touch screen, so elsewhere this is a plain wrapper. The
|
|
6
|
+
status line says the label while a refresh runs.
|
|
7
|
+
%>
|
|
8
|
+
<%
|
|
9
|
+
extra = html.dup
|
|
10
|
+
data = (extra.delete(:data) || {}).merge(studio_controller: "pull-to-refresh", pull_to_refresh_label: label)
|
|
11
|
+
data[:pull_to_refresh_frame] = frame if frame.present?
|
|
12
|
+
classes = [ "studio-pull-refresh", extra.delete(:class) ].compact.join(" ")
|
|
13
|
+
%>
|
|
14
|
+
<%= tag.div(class: classes, data: data, **extra) do %>
|
|
15
|
+
<span class="studio-pull-refresh-indicator" aria-hidden="true" hidden>
|
|
16
|
+
<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>
|
|
17
|
+
</span>
|
|
18
|
+
<p class="sr-only" role="status" data-pull-to-refresh-status></p>
|
|
19
|
+
<%= body %>
|
|
20
|
+
<% end %>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<%# locals: (kind:, count:, lines:, label:, html: {}) %>
|
|
2
|
+
<%#
|
|
3
|
+
A skeleton (Studio::LoaderHelper#studio_skeleton, docs/LOADER.md): grey shapes
|
|
4
|
+
of the content to come. The wrapper is aria-busy, so assistive tech knows the
|
|
5
|
+
region is not ready; each item is aria-hidden, since a shape means nothing
|
|
6
|
+
read aloud. One line of screen-reader text names the wait. The shimmer is
|
|
7
|
+
engine-motion.css ("Loader family"), and stops under reduced motion.
|
|
8
|
+
%>
|
|
9
|
+
<%
|
|
10
|
+
extra = html.dup
|
|
11
|
+
data = (extra.delete(:data) || {}).merge(skeleton: kind.to_s)
|
|
12
|
+
classes = [ "studio-skeleton", extra.delete(:class) ].compact.join(" ")
|
|
13
|
+
draw_lines = lambda do |total|
|
|
14
|
+
safe_join(Array.new(total) { |i| tag.span(class: "studio-skeleton-bone", data: { bone: "line" }, style: "--studio-skeleton-width: #{studio_skeleton_width(i, total)}") })
|
|
15
|
+
end
|
|
16
|
+
%>
|
|
17
|
+
<%= tag.div(class: classes, aria: { busy: "true" }, data: data, **extra) do %>
|
|
18
|
+
<% if label.present? %><span class="sr-only"><%= label %></span><% end %>
|
|
19
|
+
<% count.times do %>
|
|
20
|
+
<div class="studio-skeleton-item" data-skeleton-kind="<%= kind %>" aria-hidden="true">
|
|
21
|
+
<% case kind %>
|
|
22
|
+
<% when :avatar %>
|
|
23
|
+
<span class="studio-skeleton-bone" data-bone="avatar"></span>
|
|
24
|
+
<span class="studio-skeleton-lines"><%= draw_lines.call([ lines, 1 ].max) %></span>
|
|
25
|
+
<% when :card %>
|
|
26
|
+
<span class="studio-skeleton-bone" data-bone="media"></span>
|
|
27
|
+
<span class="studio-skeleton-bone" data-bone="title"></span>
|
|
28
|
+
<%= draw_lines.call(lines) %>
|
|
29
|
+
<% when :media %>
|
|
30
|
+
<span class="studio-skeleton-bone" data-bone="media"></span>
|
|
31
|
+
<%= draw_lines.call(lines) %>
|
|
32
|
+
<% else %>
|
|
33
|
+
<%= draw_lines.call([ lines, 1 ].max) %>
|
|
34
|
+
<% end %>
|
|
35
|
+
</div>
|
|
36
|
+
<% end %>
|
|
37
|
+
<% end %>
|
|
@@ -5,11 +5,9 @@
|
|
|
5
5
|
An app that does not draw the gallery here (lookbook not in its bundle, or
|
|
6
6
|
production without Studio.lookbook_in_production) says so instead of linking
|
|
7
7
|
to a 404. -%>
|
|
8
|
-
<section id="components" class="space-y-6"
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
<p class="text-muted text-sm max-w-2xl">One preview per state, in the component gallery.</p>
|
|
12
|
-
</div>
|
|
8
|
+
<section id="components" class="section-target space-y-6">
|
|
9
|
+
<%= render "studio/section_header", title: "Components" %>
|
|
10
|
+
<p class="text-muted text-sm max-w-2xl">One preview per state, in the component gallery.</p>
|
|
13
11
|
|
|
14
12
|
<% if Studio.lookbook_mounted? %>
|
|
15
13
|
<ul class="grid gap-4 sm:grid-cols-2" data-component-previews>
|
|
@@ -90,12 +90,9 @@
|
|
|
90
90
|
A reader who assumes dsModals will build a mirror instead: a second copy of
|
|
91
91
|
every card, free to drift from the one the app actually ships, which is the
|
|
92
92
|
opposite of what a living style guide is for. %>
|
|
93
|
-
<section id="host-modals" class="space-y-8" x-data="{}"
|
|
94
|
-
|
|
95
|
-
<
|
|
96
|
-
<h2 class="type-h3 text-heading"><%= Studio.app_name %></h2>
|
|
97
|
-
<p class="text-muted text-sm max-w-2xl">This app’s own modals, from <code class="font-mono text-2xs">app/views/style/host/_modals.html.erb</code>.</p>
|
|
98
|
-
</div>
|
|
93
|
+
<section id="host-modals" class="section-target space-y-8" x-data="{}">
|
|
94
|
+
<%= render "studio/section_header", title: Studio.app_name %>
|
|
95
|
+
<p class="text-muted text-sm max-w-2xl">This app’s own modals, from <code class="font-mono text-2xs">app/views/style/host/_modals.html.erb</code>.</p>
|
|
99
96
|
|
|
100
97
|
<%# The app's content. Reached only when it resolves - style/index.html.erb
|
|
101
98
|
owns that guard, because the section nav needs the same answer. %>
|
|
@@ -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 %>
|
|
@@ -9,11 +9,11 @@
|
|
|
9
9
|
page when chatting (item 1).
|
|
10
10
|
- an optional row of option toggles lets the user configure the modal
|
|
11
11
|
before opening; their clicks/keys do NOT bubble to the card open (item 2).
|
|
12
|
-
- an optional active-card GLOW (.
|
|
12
|
+
- an optional active-card GLOW (.edge-rotate-glow) that reactively lights
|
|
13
13
|
when this card represents the currently-open modal + step, and slides to
|
|
14
14
|
the next card as the step machine advances (items 5/6). The glow rides an
|
|
15
15
|
un-clipped wrapper (the card itself is overflow-hidden), mirroring TM's
|
|
16
|
-
holo-wrap / holo-card split, and fades via --
|
|
16
|
+
holo-wrap / holo-card split, and fades via --edge-rotate-glow-opacity so
|
|
17
17
|
it transitions smoothly between cards.
|
|
18
18
|
|
|
19
19
|
Locals:
|
|
@@ -53,10 +53,10 @@
|
|
|
53
53
|
<%# Wrapper — carries the active-card glow (un-clipped) + the shared x-data. %>
|
|
54
54
|
<%# h-full so the card stretches to its grid row's tallest (per-row equal
|
|
55
55
|
height via the grid's default align-items: stretch). %>
|
|
56
|
-
<div class="relative h-full<%= "
|
|
56
|
+
<div class="relative h-full<%= " edge-rotate-glow rounded-xl" if glow_when %>"
|
|
57
57
|
x-data="<%= card_x_data %>"
|
|
58
58
|
<%# Fail-closed default: a STATIC inline 0 out-specifies the primitive's CSS
|
|
59
|
-
default (--
|
|
59
|
+
default (--edge-rotate-glow-opacity of 1) so the ring is OFF at first
|
|
60
60
|
paint, BEFORE Alpine hydrates the reactive :style below. Without it every
|
|
61
61
|
glow card shows its ring until Alpine boots (the fail-open flash). The
|
|
62
62
|
:style then drives 0 to 0.95 with the 0.4s cross-fade. Keep BOTH.
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
the Turbo-nav store gap fixed in 0.26.1 (style/_modals dual-guards the
|
|
68
68
|
dsModals registration so it survives Turbo visits, not just first load).
|
|
69
69
|
So the guarantee is: static inline 0 PLUS a registered dsModals. %>
|
|
70
|
-
<% if glow_when %>style="--
|
|
70
|
+
<% if glow_when %>style="--edge-rotate-glow-opacity: 0" :style="{ '--edge-rotate-glow-opacity': (<%= glow_when %>) ? '0.95' : '0' }"<% end %>>
|
|
71
71
|
<article
|
|
72
72
|
class="card overflow-hidden flex flex-col h-full<%= " opacity-95" if disabled %><%= " cursor-pointer" if clickable %>"
|
|
73
73
|
<%= 'aria-disabled="true"'.html_safe if disabled %>
|
|
@@ -150,14 +150,12 @@
|
|
|
150
150
|
target moved. The engine primitive is untouched; its real submit still posts
|
|
151
151
|
to the app-supplied submit_url. Kept on the section (always mounted) so it
|
|
152
152
|
fires even as the age-gate card closes. %>
|
|
153
|
-
<section id="modals" class="space-y-10"
|
|
153
|
+
<section id="modals" class="section-target space-y-10"
|
|
154
154
|
data-studio-controller="style-modals"
|
|
155
155
|
x-data="{}" @ds-modal-close.window="$store.dsModals.close()"
|
|
156
156
|
@age-verified.window="$store.dsModals.open('entry-confirmed', { txSignature: null, lobbyUrl: null, seedsEarned: 0 })">
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
<p class="text-muted text-sm">Click a card to open the real modal.</p>
|
|
160
|
-
</div>
|
|
157
|
+
<%= render "studio/section_header", title: "Modals" %>
|
|
158
|
+
<p class="text-muted text-sm">Click a card to open the real modal.</p>
|
|
161
159
|
|
|
162
160
|
<%# ---- The section's own modal host (dsModals) --------------------------
|
|
163
161
|
$store.dsModals is the engine's modal stack under the guide's own name:
|
|
@@ -1169,6 +1167,18 @@
|
|
|
1169
1167
|
</div>
|
|
1170
1168
|
</section>
|
|
1171
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
|
+
|
|
1172
1182
|
<%# ===================================================================== %>
|
|
1173
1183
|
<%# 7. REWARDS / leveling (gated by Studio.feature?(:leveling)) %>
|
|
1174
1184
|
<%# ===================================================================== %>
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
sample_page = [ [ "Google's header", 0, 200, false ], [ "Select an appointment time", 200, 600, true ],
|
|
34
34
|
[ "Google's credit lines", 600, 720, false ] ]
|
|
35
35
|
%>
|
|
36
|
-
<section id="site-footer" class="space-y-5"
|
|
36
|
+
<section id="site-footer" class="section-subtarget space-y-5">
|
|
37
37
|
<div class="space-y-1">
|
|
38
38
|
<h3 class="type-h4 text-heading">Site footer and booking</h3>
|
|
39
39
|
<p class="text-muted text-sm max-w-2xl">One layout line renders the footer, and <code class="font-mono text-2xs">config.booking_url</code> turns on booking.</p>
|
|
@@ -1,11 +1,13 @@
|
|
|
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.
|
|
8
9
|
usage (String, optional) - a copyable usage snippet.
|
|
10
|
+
note (String, optional) - one short sentence on what it does.
|
|
9
11
|
disabled (Boolean, optional) - render "disabled but present": the
|
|
10
12
|
primitive stays visible + previewable but is muted/greyed
|
|
11
13
|
and flagged, for a capability the host app has turned off
|
|
@@ -38,20 +40,29 @@
|
|
|
38
40
|
<% end %>
|
|
39
41
|
</div>
|
|
40
42
|
<div class="border-t border-subtle p-3 space-y-2">
|
|
41
|
-
|
|
43
|
+
<% if local_assigns[:note].present? %>
|
|
44
|
+
<p class="text-sm text-body"><%= local_assigns[:note] %></p>
|
|
45
|
+
<% end %>
|
|
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] %>
|
|
42
49
|
<div class="flex items-start justify-between gap-2" x-data="{ copied: false }">
|
|
43
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>
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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 %>
|
|
50
59
|
</div>
|
|
51
|
-
<% snippet = local_assigns[:usage] %>
|
|
52
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. %>
|
|
53
64
|
<div class="relative" x-data="{ copied: false }">
|
|
54
|
-
<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>
|
|
55
66
|
<button type="button"
|
|
56
67
|
class="btn btn-neutral btn-sm absolute top-2 right-2"
|
|
57
68
|
aria-label="Copy usage snippet"
|
|
@@ -63,11 +63,9 @@
|
|
|
63
63
|
"stage-shipped" => [{ title: "SES email backend", repo: "mcritchie", who: "ST" }]
|
|
64
64
|
}
|
|
65
65
|
%>
|
|
66
|
-
<section id="tasks" class="space-y-8"
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
<p class="text-muted text-sm max-w-2xl">The real board primitive in demo mode, so a drag saves nothing.</p>
|
|
70
|
-
</div>
|
|
66
|
+
<section id="tasks" class="section-target space-y-8">
|
|
67
|
+
<%= render "studio/section_header", title: "Tasks" %>
|
|
68
|
+
<p class="text-muted text-sm max-w-2xl">The real board primitive in demo mode, so a drag saves nothing.</p>
|
|
71
69
|
|
|
72
70
|
<%# ---- The LIVE primitive (demo mode) --------------------------------- %>
|
|
73
71
|
<%# demo: true → drag animates + toasts locally, nothing POSTs. %>
|
|
@@ -53,11 +53,11 @@
|
|
|
53
53
|
|
|
54
54
|
csrf_ok = respond_to?(:protect_against_forgery?) && protect_against_forgery?
|
|
55
55
|
%>
|
|
56
|
-
<section id="theme" class="space-y-8"
|
|
56
|
+
<section id="theme" class="section-target space-y-8"
|
|
57
57
|
x-data="dsThemeEditor(<%= seed_colors.to_json %>)">
|
|
58
|
+
<%= render "studio/section_header", title: "Theme" %>
|
|
58
59
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
|
59
60
|
<div class="space-y-1">
|
|
60
|
-
<h2 class="type-h3 text-heading">Theme</h2>
|
|
61
61
|
<p class="text-muted text-sm max-w-2xl">Every specimen on this page restyles from these 7 color roles.</p>
|
|
62
62
|
</div>
|
|
63
63
|
<form action="/admin/theme/regenerate" method="post" accept-charset="UTF-8" @submit.prevent="regenerate($event)">
|