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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +192 -0
  3. data/README.md +48 -6
  4. data/app/assets/tailwind/studio_engine/engine-motion.css +322 -56
  5. data/app/assets/tailwind/studio_engine/engine.css +737 -19
  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/fizz_helper.rb +5 -0
  15. data/app/helpers/studio/loader_helper.rb +79 -2
  16. data/app/javascript/studio/agent_chat.js +294 -6
  17. data/app/javascript/studio/carousel.js +99 -0
  18. data/app/javascript/studio/controllers/carousel_controller.js +138 -0
  19. data/app/javascript/studio/controllers/infinite_scroll_controller.js +17 -0
  20. data/app/javascript/studio/controllers/pull_to_refresh_controller.js +18 -0
  21. data/app/javascript/studio/controllers/style_section_nav_controller.js +4 -4
  22. data/app/javascript/studio/controllers/talk_to_agent_controller.js +20 -0
  23. data/app/javascript/studio/infinite_scroll.js +142 -0
  24. data/app/javascript/studio/loader.js +6 -1
  25. data/app/javascript/studio/page_transitions.js +65 -8
  26. data/app/javascript/studio/pinned_stack.js +112 -4
  27. data/app/javascript/studio/pull_to_refresh.js +147 -0
  28. data/app/javascript/studio/stimulus.js +12 -1
  29. data/app/javascript/studio/talk_to_agent.js +116 -0
  30. data/app/views/components/_geo_signpost.html.erb +2 -2
  31. data/app/views/components/_link_sidebar.html.erb +2 -2
  32. data/app/views/components/_link_sidebar_trigger.html.erb +1 -1
  33. data/app/views/components/_sidebar_panel.html.erb +1 -1
  34. data/app/views/error_logs/index.html.erb +2 -8
  35. data/app/views/studio/_carousel.html.erb +97 -0
  36. data/app/views/studio/_fizz.html.erb +26 -0
  37. data/app/views/studio/_fizz_layer.html.erb +4 -1
  38. data/app/views/studio/_hold_button.html.erb +1 -2
  39. data/app/views/studio/_page_header.html.erb +55 -13
  40. data/app/views/studio/_section_header.html.erb +23 -0
  41. data/app/views/studio/agent_chat/_chat.html.erb +7 -4
  42. data/app/views/studio/agent_chat/_talk_modal.html.erb +58 -0
  43. data/app/views/studio/agent_chat/_turn.html.erb +1 -1
  44. data/app/views/studio/loader/_infinite_scroll.html.erb +42 -0
  45. data/app/views/studio/loader/_loader.html.erb +20 -3
  46. data/app/views/studio/loader/_pull_to_refresh.html.erb +20 -0
  47. data/app/views/studio/loader/_skeleton.html.erb +37 -0
  48. data/app/views/style/_components.html.erb +3 -5
  49. data/app/views/style/_host.html.erb +3 -6
  50. data/app/views/style/_loaders.html.erb +118 -0
  51. data/app/views/style/_loaders_feed_page.html.erb +16 -0
  52. data/app/views/style/_modal_specimen.html.erb +5 -5
  53. data/app/views/style/_modals.html.erb +15 -5
  54. data/app/views/style/_site_footer.html.erb +1 -1
  55. data/app/views/style/_specimen.html.erb +21 -10
  56. data/app/views/style/_tasks.html.erb +3 -5
  57. data/app/views/style/_theme.html.erb +2 -2
  58. data/app/views/style/_tricks.html.erb +218 -217
  59. data/app/views/style/index.html.erb +21 -11
  60. data/app/views/style/loaders_feed.html.erb +7 -0
  61. data/lib/studio/crumbs.rb +150 -0
  62. data/lib/studio/loader.rb +30 -2
  63. data/lib/studio/version.rb +1 -1
  64. data/lib/studio.rb +3 -0
  65. 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. Text only, never HTML: the script sets textContent. A failed turn says so. A held one (stored, no automatic answer coming) says that, and never asks for a resend. %>
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
- aria = pending ? { hidden: "true" } : { live: "polite" }
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: (pending ? nil : "status"), aria: aria, data: data, style: vars.join("; "), **extra) do %>
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" style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)">
9
- <div class="space-y-1">
10
- <h2 class="type-h3 text-heading">Components</h2>
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
- style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)">
95
- <div class="space-y-1">
96
- <h2 class="type-h3 text-heading"><%= Studio.app_name %></h2>
97
- <p class="text-muted text-sm max-w-2xl">This app&rsquo;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&rsquo;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 (.studio-team-glow) that reactively lights
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 --studio-team-glow-opacity so
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<%= " studio-team-glow rounded-xl" if glow_when %>"
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 (--studio-team-glow-opacity of 1) so the ring is OFF at first
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="--studio-team-glow-opacity: 0" :style="{ '--studio-team-glow-opacity': (<%= glow_when %>) ? '0.95' : '0' }"<% end %>>
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" style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)"
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
- <div class="space-y-1">
158
- <h2 class="type-h3 text-heading">Modals</h2>
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" style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)">
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 (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.
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
- <%# Class name — prominent, with a one-click copy so an agent can grab it. %>
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
- <button type="button"
45
- class="btn btn-neutral btn-sm shrink-0"
46
- aria-label="Copy class name"
47
- @click="navigator.clipboard?.writeText($refs.klass.innerText); copied = true; setTimeout(() => copied = false, 1200)">
48
- <span x-text="copied ? 'Copied' : 'Copy'">Copy</span>
49
- </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 %>
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-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>
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" style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)">
67
- <div class="space-y-1">
68
- <h2 class="type-h3 text-heading">Tasks</h2>
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" style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)"
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)">