studio-engine 0.127.0 → 0.129.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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +161 -0
  3. data/README.md +9 -3
  4. data/app/assets/javascripts/studio/page_transition_reveal.js +1 -1
  5. data/app/assets/tailwind/studio_engine/engine.css +572 -37
  6. data/app/controllers/concerns/studio/error_handling.rb +17 -0
  7. data/app/controllers/studio/surveys_controller.rb +16 -3
  8. data/app/helpers/studio/loader_helper.rb +2 -2
  9. data/app/helpers/studio_icon_helper.rb +10 -0
  10. data/app/javascript/studio/agent_chat.js +88 -6
  11. data/app/javascript/studio/agent_chat_voice.js +59 -5
  12. data/app/javascript/studio/alpine_stores.js +80 -22
  13. data/app/javascript/studio/loader.js +130 -5
  14. data/app/javascript/studio/microphone_chip.js +28 -2
  15. data/app/javascript/studio/page_transitions.js +28 -19
  16. data/app/javascript/studio/survey.js +16 -3
  17. data/app/javascript/studio/theme_editor.js +4 -0
  18. data/app/javascript/studio/voice_recorder.js +18 -7
  19. data/app/models/studio/survey_response.rb +24 -17
  20. data/app/views/components/_geo_signpost.html.erb +1 -0
  21. data/app/views/components/_link_sidebar.html.erb +40 -12
  22. data/app/views/components/_sidebar_panel.html.erb +15 -5
  23. data/app/views/layouts/studio/_head.html.erb +18 -7
  24. data/app/views/layouts/studio/_smooth_load.html.erb +1 -0
  25. data/app/views/layouts/studio/component_preview.html.erb +1 -1
  26. data/app/views/registrations/new.html.erb +4 -3
  27. data/app/views/sessions/new.html.erb +4 -3
  28. data/app/views/studio/agent_chat/_chat.html.erb +2 -1
  29. data/app/views/studio/agent_chat/_turn.html.erb +3 -1
  30. data/app/views/studio/agent_chat/_voice.html.erb +28 -13
  31. data/app/views/studio/loader/_overlay.html.erb +4 -4
  32. data/app/views/style/_components.html.erb +2 -4
  33. data/app/views/style/_glass.html.erb +83 -0
  34. data/app/views/style/_host.html.erb +2 -10
  35. data/app/views/style/_modals.html.erb +38 -231
  36. data/app/views/style/_scrims.html.erb +2 -9
  37. data/app/views/style/_site_footer.html.erb +5 -13
  38. data/app/views/style/_tasks.html.erb +16 -27
  39. data/app/views/style/_theme.html.erb +56 -78
  40. data/app/views/style/_tricks.html.erb +13 -11
  41. data/app/views/style/_type_ladder.html.erb +33 -0
  42. data/app/views/style/index.html.erb +5 -1
  43. data/docs/SITE_FOOTER.md +1 -1
  44. data/lib/studio/agent_chat.rb +4 -2
  45. data/lib/studio/icons/README.md +12 -0
  46. data/lib/studio/icons/lucide/LICENSE +43 -0
  47. data/lib/studio/icons/lucide/activity.svg +15 -0
  48. data/lib/studio/icons/lucide/book-open.svg +16 -0
  49. data/lib/studio/icons/lucide/chart-column.svg +18 -0
  50. data/lib/studio/icons/lucide/diamond.svg +15 -0
  51. data/lib/studio/icons/lucide/door-stairwell.svg +19 -0
  52. data/lib/studio/icons/lucide/globe.svg +17 -0
  53. data/lib/studio/icons/lucide/hammer.svg +17 -0
  54. data/lib/studio/icons/lucide/inbox.svg +16 -0
  55. data/lib/studio/icons/lucide/key-round.svg +16 -0
  56. data/lib/studio/icons/lucide/layout-grid.svg +18 -0
  57. data/lib/studio/icons/lucide/log-out.svg +17 -0
  58. data/lib/studio/icons/lucide/message-circle.svg +15 -0
  59. data/lib/studio/icons/lucide/package.svg +18 -0
  60. data/lib/studio/icons/lucide/palette.svg +19 -0
  61. data/lib/studio/icons/lucide/plug.svg +18 -0
  62. data/lib/studio/icons/lucide/rocket.svg +18 -0
  63. data/lib/studio/icons/lucide/user.svg +16 -0
  64. data/lib/studio/icons.rb +52 -0
  65. data/lib/studio/loader.rb +7 -0
  66. data/lib/studio/sidebar_sections.rb +52 -4
  67. data/lib/studio/site_footer.rb +23 -2
  68. data/lib/studio/version.rb +1 -1
  69. data/lib/studio.rb +54 -2
  70. metadata +24 -1
@@ -1,6 +1,6 @@
1
1
  <%# Theme section of the living style guide (admin/style) — the LANDING section.
2
- Theme owns "the colors": it leads with the 7 role tokens/swatches, then the
3
- editor with a live dark/light preview, then the logos, the semantic tokens and
2
+ Theme owns "the colors": it leads with the role editor, which names each role's CSS
3
+ variable and the derived tokens, with a live dark/light preview, then the logos, the semantic tokens and
4
4
  the type. GET /admin/theme redirects here.
5
5
 
6
6
  Save-in-place: this section intercepts Save + Regenerate and submits them via
@@ -42,17 +42,14 @@
42
42
  # Seed the live-preview editor with role -> hex, resolved from saved values.
43
43
  seed_colors = roles.index_with { |role| current_hex.call(role) }
44
44
 
45
- # The 7 role tokens read live off the resolved theme (cta/text/border-strong
46
- # are the semantic names — there is no --color-heading var).
47
- token_swatches = [
48
- ["--color-cta", "CTA / primary"],
49
- ["--color-surface", "Surface"],
50
- ["--color-success", "Success"],
51
- ["--color-danger", "Danger"],
52
- ["--color-warning", "Warning"],
53
- ["--color-text", "Heading / text"],
54
- ["--color-border-strong", "Border (strong)"]
55
- ]
45
+ # Each role's CSS variable (there is no --color-heading or --color-primary var for a page to
46
+ # paint with: cta and text are the semantic names). Dark and light are each theme's page.
47
+ role_vars = { primary: "--color-cta", dark: "--color-page (dark)", light: "--color-page (light)", success: "--color-success",
48
+ warning: "--color-warning", danger: "--color-danger", accent: "--color-accent" }
49
+
50
+ # Not roles: Studio::ThemeResolver works these out from the roles, so they read here and
51
+ # no one edits them.
52
+ derived_tokens = [["Surface", "--color-surface"], ["Text", "--color-text"], ["Border (strong)", "--color-border-strong"]]
56
53
 
57
54
  csrf_ok = respond_to?(:protect_against_forgery?) && protect_against_forgery?
58
55
  %>
@@ -60,12 +57,8 @@
60
57
  x-data="dsThemeEditor(<%= seed_colors.to_json %>)">
61
58
  <div class="flex flex-wrap items-start justify-between gap-3">
62
59
  <div class="space-y-1">
63
- <h2 class="text-2xl font-bold text-heading">Theme</h2>
64
- <p class="text-muted text-sm max-w-2xl">
65
- The color foundation. The 7-role system, editable here. Edits preview live; Save writes them
66
- and Regenerate clears the theme cache. Both persist <strong>in place</strong> — the page never
67
- leaves. Every specimen on this page restyles from these roles.
68
- </p>
60
+ <h2 class="type-h3 text-heading">Theme</h2>
61
+ <p class="text-muted text-sm max-w-2xl">Every specimen on this page restyles from these 7 color roles.</p>
69
62
  </div>
70
63
  <form action="/admin/theme/regenerate" method="post" accept-charset="UTF-8" @submit.prevent="regenerate($event)">
71
64
  <% if csrf_ok %>
@@ -75,25 +68,32 @@
75
68
  </form>
76
69
  </div>
77
70
 
78
- <%# ---- The colors — the role tokens/swatches (Theme owns these) ---------- %>
79
- <div class="space-y-3">
80
- <p class="label-upper">The colors — 7 role tokens</p>
81
- <p class="text-muted text-sm max-w-2xl">
82
- Read live off this app's theme via <code class="font-mono text-2xs">var(--color-*)</code>. Change
83
- the app or its light/dark mode and every swatch (and every specimen on this page) follows.
84
- </p>
85
- <div class="grid gap-4 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
86
- <% token_swatches.each do |var, label| %>
87
- <div class="card overflow-hidden">
88
- <div class="h-16 border-b border-subtle" style="background: var(<%= var %>)"></div>
89
- <div class="p-3 space-y-0.5">
90
- <p class="text-sm text-heading font-medium"><%= label %></p>
91
- <code class="font-mono text-2xs text-muted break-all"><%= var %></code>
71
+ <%# THE LOGOS, first in the section. A host may supply its own block in
72
+ app/views/style/host/_logos.html.erb, the same seam as host modals: the hub's has
73
+ one-click PNG downloads, which draw through a script only the hub carries. %>
74
+ <% if lookup_context.exists?("logos", ["style/host"], true) %>
75
+ <%= render "style/host/logos" %>
76
+ <% else %>
77
+ <% logos = Studio.respond_to?(:theme_logos) ? Array(Studio.theme_logos) : [] %>
78
+ <% if logos.any? %>
79
+ <div class="space-y-3">
80
+ <p class="label-upper">Logos</p>
81
+ <div class="grid gap-4 grid-cols-2 md:grid-cols-5">
82
+ <% logos.each do |logo| %>
83
+ <% file = logo.is_a?(Hash) ? logo[:file] : logo %>
84
+ <% title = logo.is_a?(Hash) ? logo[:title] : file %>
85
+ <div class="card p-4 space-y-3">
86
+ <p class="text-sm text-heading font-medium"><%= title %></p>
87
+ <div class="flex items-center justify-center w-20 h-20 rounded-lg" style="background-color: #ddd; background-image: linear-gradient(45deg, #777 25%, transparent 25%, transparent 75%, #777 75%), linear-gradient(45deg, #777 25%, transparent 25%, transparent 75%, #777 75%); background-size: 12px 12px; background-position: 0 0, 6px 6px;">
88
+ <img src="/<%= file %>" alt="<%= title %>" class="max-w-16 max-h-16 rounded">
89
+ </div>
90
+ <code class="font-mono text-2xs text-muted break-all"><%= file %></code>
92
91
  </div>
93
- </div>
94
- <% end %>
92
+ <% end %>
93
+ </div>
95
94
  </div>
96
- </div>
95
+ <% end %>
96
+ <% end %>
97
97
 
98
98
  <div class="grid gap-6 lg:grid-cols-2">
99
99
  <%# ---- Role-color editor (posts to /admin/theme, intercepted in place) - %>
@@ -103,7 +103,7 @@
103
103
  <% if csrf_ok %>
104
104
  <input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>" autocomplete="off">
105
105
  <% end %>
106
- <h3 class="text-lg font-bold text-heading">Role colors</h3>
106
+ <h3 class="type-h4 text-heading">Role colors</h3>
107
107
 
108
108
  <% roles.each do |role| %>
109
109
  <% db_col = db_col_for.call(role) %>
@@ -119,6 +119,7 @@
119
119
  <div class="flex items-center gap-2">
120
120
  <span class="text-heading font-medium text-sm"><%= role.to_s.titleize %></span>
121
121
  <% if has_custom %><span class="text-xs text-muted">(custom)</span><% end %>
122
+ <% if role_vars[role.to_sym] %><code class="font-mono text-2xs text-muted truncate"><%= role_vars[role.to_sym] %></code><% end %>
122
123
  </div>
123
124
  <input type="text"
124
125
  name="theme_setting[<%= db_col %>]"
@@ -135,16 +136,29 @@
135
136
  </div>
136
137
  <% end %>
137
138
 
139
+ <div class="pt-4 border-t border-subtle space-y-2" data-test="style-derived-tokens">
140
+ <p class="label-upper">Derived, read only</p>
141
+ <% derived_tokens.each do |label, var| %>
142
+ <div class="flex items-center gap-4">
143
+ <div class="w-10 h-6 rounded border border-subtle shrink-0" style="background: var(<%= var %>)"></div>
144
+ <span class="text-heading font-medium text-sm"><%= label %></span>
145
+ <code class="font-mono text-2xs text-muted truncate"><%= var %></code>
146
+ </div>
147
+ <% end %>
148
+ </div>
149
+
138
150
  <div class="pt-4 border-t border-subtle">
139
151
  <button type="submit" class="btn btn-primary" :disabled="saving"
140
152
  x-text="saving ? 'Saving…' : 'Save theme'">Save theme</button>
141
153
  </div>
142
154
  </form>
143
155
 
144
- <%# ---- Live dark/light preview (Alpine, updates as you edit) ----------- %>
145
- <div class="space-y-3">
156
+ <%# The half beside the editor: Typography, then the live dark/light preview, which
157
+ appears once a role color has been edited. %>
158
+ <div class="space-y-6 min-w-0">
159
+ <%= render "style/type_ladder" %>
160
+ <div class="space-y-3" x-show="edited" x-cloak data-test="style-theme-preview">
146
161
  <p class="label-upper">Preview</p>
147
- <p class="text-muted text-sm">A live dark/light mock — it tracks the editor as you type, before you save.</p>
148
162
  <div class="grid gap-3 sm:grid-cols-2">
149
163
  <div class="rounded-xl border border-subtle overflow-hidden">
150
164
  <div class="px-3 py-1.5 bg-surface-alt"><span class="text-2xs font-bold uppercase tracking-wider text-secondary">Dark</span></div>
@@ -168,28 +182,9 @@
168
182
  </div>
169
183
  </div>
170
184
  </div>
171
- </div>
172
-
173
- <% logos = Studio.respond_to?(:theme_logos) ? Array(Studio.theme_logos) : [] %>
174
- <% if logos.any? %>
175
- <div class="space-y-3">
176
- <p class="label-upper">Logos</p>
177
- <div class="grid gap-4 grid-cols-2 md:grid-cols-5">
178
- <% logos.each do |logo| %>
179
- <% file = logo.is_a?(Hash) ? logo[:file] : logo %>
180
- <% title = logo.is_a?(Hash) ? logo[:title] : file %>
181
- <div class="card p-4 space-y-3">
182
- <p class="text-sm text-heading font-medium"><%= title %></p>
183
- <div class="flex items-center justify-center w-20 h-20 rounded-lg" style="background-color: #ddd; background-image: linear-gradient(45deg, #777 25%, transparent 25%, transparent 75%, #777 75%), linear-gradient(45deg, #777 25%, transparent 25%, transparent 75%, #777 75%); background-size: 12px 12px; background-position: 0 0, 6px 6px;">
184
- <img src="/<%= file %>" alt="<%= title %>" class="max-w-16 max-h-16 rounded">
185
- </div>
186
- <code class="font-mono text-2xs text-muted break-all"><%= file %></code>
187
- </div>
188
- <% end %>
189
- </div>
190
185
  </div>
191
- <% end %>
192
-
186
+ </div>
187
+
193
188
  <%# Semantic tokens: the utilities a page paints with, under the role colours. %>
194
189
  <div class="space-y-3">
195
190
  <p class="label-upper">Semantic tokens</p>
@@ -217,21 +212,4 @@
217
212
  </div>
218
213
  </div>
219
214
 
220
- <%# Type in this app's own font, named by role: the font itself is the host's. %>
221
- <div class="space-y-3">
222
- <p class="label-upper">Typography</p>
223
- <div class="card p-6 space-y-4">
224
- <p class="text-heading text-3xl font-extrabold">Heading</p>
225
- <p class="text-heading text-xl font-bold">Subheading</p>
226
- <p class="text-body text-base">Body text. The quick brown fox jumps over the lazy dog.</p>
227
- <p class="text-secondary text-sm">Secondary text. Additional context and metadata.</p>
228
- <p class="text-muted text-xs">Muted text. Timestamps, hints and fine print.</p>
229
- <hr class="border-subtle">
230
- <div class="flex flex-wrap gap-4">
231
- <% [400, 500, 600, 700, 800, 900].each do |weight| %>
232
- <span class="text-heading text-lg" style="font-weight: <%= weight %>"><%= weight %></span>
233
- <% end %>
234
- </div>
235
- </div>
236
- </div>
237
215
  </section>
@@ -33,7 +33,7 @@
33
33
  %>
34
34
  <section id="tricks" class="space-y-12" style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)">
35
35
  <div class="space-y-1">
36
- <h2 class="text-2xl font-bold text-heading">Tricks</h2>
36
+ <h2 class="type-h3 text-heading">Tricks</h2>
37
37
  <p class="text-muted text-sm max-w-2xl">
38
38
  A board of style tricks you can see and hand to an agent to use out of the box.
39
39
  Each specimen surfaces the rendered primitive, its <strong>class name (one-click
@@ -45,7 +45,7 @@
45
45
  <!-- Buttons -->
46
46
  <section class="space-y-5">
47
47
  <div class="space-y-1">
48
- <h3 class="text-xl font-bold text-heading">Buttons</h3>
48
+ <h3 class="type-h4 text-heading">Buttons</h3>
49
49
  <p class="text-muted text-sm">
50
50
  <code class="font-mono text-2xs">.btn</code> base plus one role variant, and the
51
51
  <code class="font-mono text-2xs">.btn-sm</code> / <code class="font-mono text-2xs">.btn-lg</code> sizes.
@@ -75,7 +75,7 @@
75
75
  <!-- Page header -->
76
76
  <section class="space-y-5" data-page-header-specimen>
77
77
  <div class="space-y-1">
78
- <h3 class="text-xl font-bold text-heading">Page header</h3>
78
+ <h3 class="type-h4 text-heading">Page header</h3>
79
79
  <p class="text-muted text-sm">
80
80
  <code class="font-mono text-2xs">studio/_page_header</code>: the title, optional meta beside
81
81
  it and optional tabs on the right. This page's own title row is one: scroll to see it take the bar.
@@ -110,7 +110,7 @@
110
110
  <!-- Surfaces, text and form -->
111
111
  <section class="space-y-5">
112
112
  <div class="space-y-1">
113
- <h3 class="text-xl font-bold text-heading">Surfaces, text &amp; form</h3>
113
+ <h3 class="type-h4 text-heading">Surfaces, text &amp; form</h3>
114
114
  <p class="text-muted text-sm">Cards, badges, inputs, the empty state, and the uppercase meta label.</p>
115
115
  </div>
116
116
  <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
@@ -152,12 +152,14 @@
152
152
  </section>
153
153
 
154
154
  <!-- Tints and shades: the scrim primitive -->
155
+ <%= render "style/glass" %>
156
+
155
157
  <%= render "style/scrims" %>
156
158
 
157
159
  <!-- Time stamps: the "at" format -->
158
160
  <section class="space-y-5">
159
161
  <div class="space-y-1">
160
- <h3 class="text-xl font-bold text-heading">Time stamps — the &ldquo;at&rdquo; format</h3>
162
+ <h3 class="type-h4 text-heading">Time stamps — the &ldquo;at&rdquo; format</h3>
161
163
  <p class="text-muted text-sm">
162
164
  <code class="font-mono text-2xs">at_time_tag</code> stamps WHEN something happened, on
163
165
  the reader&rsquo;s own clock. The stamps below are live: the server rendered them in this
@@ -231,7 +233,7 @@
231
233
  %>
232
234
  <section id="loader" class="space-y-5" style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)">
233
235
  <div class="space-y-1">
234
- <h3 class="text-xl font-bold text-heading">Branded loader</h3>
236
+ <h3 class="type-h4 text-heading">Branded loader</h3>
235
237
  <p class="text-muted text-sm max-w-2xl">
236
238
  This app's own icon in its theme colours, in the style <code class="font-mono text-2xs">config.loader</code>
237
239
  names (<strong><%= loader_config[:style].to_s.tr("_", " ") %></strong>). Page loads, long waits and pending
@@ -282,7 +284,7 @@
282
284
  <!-- Motion primitives -->
283
285
  <section class="space-y-5">
284
286
  <div class="space-y-1">
285
- <h3 class="text-xl font-bold text-heading">Motion primitives</h3>
287
+ <h3 class="type-h4 text-heading">Motion primitives</h3>
286
288
  <p class="text-muted text-sm">
287
289
  The seven <code class="font-mono text-2xs">engine-motion.css</code> primitives. Each knob is a themeable
288
290
  CSS variable - tune it inline with <code class="font-mono text-2xs">style="--knob: value"</code>.
@@ -369,7 +371,7 @@
369
371
  <!-- Effects -->
370
372
  <section class="space-y-5">
371
373
  <div class="space-y-1">
372
- <h3 class="text-xl font-bold text-heading">Effects</h3>
374
+ <h3 class="type-h4 text-heading">Effects</h3>
373
375
  <p class="text-muted text-sm">
374
376
  The <code class="font-mono text-2xs">engine-motion.css</code> visual-effect primitives beyond motion -
375
377
  gradient text, a soft glow halo, a one- or two-color rotating <strong>selection glow</strong>
@@ -434,7 +436,7 @@
434
436
  <!-- Hold to confirm -->
435
437
  <section class="space-y-5">
436
438
  <div class="space-y-1">
437
- <h3 class="text-xl font-bold text-heading">Hold to confirm</h3>
439
+ <h3 class="type-h4 text-heading">Hold to confirm</h3>
438
440
  <p class="text-muted text-sm max-w-2xl">
439
441
  A press-and-hold CTA for an action you do not want taken by accident, and the
440
442
  carbonation behind it. Ported from Turf Monster, where it confirms a contest entry.
@@ -497,7 +499,7 @@
497
499
  <!-- Confetti & pulse -->
498
500
  <section class="space-y-5">
499
501
  <div class="space-y-1">
500
- <h3 class="text-xl font-bold text-heading">Confetti &amp; pulse</h3>
502
+ <h3 class="type-h4 text-heading">Confetti &amp; pulse</h3>
501
503
  <p class="text-muted text-sm max-w-2xl">
502
504
  Celebration effects lifted from Turf Monster into the engine. Confetti is
503
505
  <code class="font-mono text-2xs">window.studioConfetti</code> - a zero-dependency,
@@ -544,7 +546,7 @@
544
546
  <section class="space-y-5">
545
547
  <div class="space-y-1">
546
548
  <div class="flex flex-wrap items-center gap-3">
547
- <h3 class="text-xl font-bold text-heading">Leveling</h3>
549
+ <h3 class="type-h4 text-heading">Leveling</h3>
548
550
  <% if leveling_on %>
549
551
  <span class="badge" style="color: var(--color-success-ink); border-color: var(--color-success)">enabled on this app</span>
550
552
  <% else %>
@@ -0,0 +1,33 @@
1
+ <%# Typography, in this app's own font. The type ladder, on trial on this page: each named
2
+ role, its pixel size at this page's density, and what it is for; then the text inks and
3
+ the weights. A role sets size, not the tag. Body and fine print follow the
4
+ page's base, so they read 14 and 10.5 on a dense admin page and 16 and 12 on a public one. %>
5
+ <% dense = respond_to?(:type_dense_page?) && type_dense_page? %>
6
+ <%
7
+ ladder = [
8
+ ["type-h1", "48px", "Marketing headline"],
9
+ ["type-h2", "30px", "Page title on a tool page"],
10
+ ["type-h3", "20px", "Section"],
11
+ ["type-h4", "16px", "Subsection"],
12
+ ["type-body", dense ? "14px" : "16px", "Body text"],
13
+ ["type-fine", dense ? "10.5px" : "12px", "Fine print"]
14
+ ]
15
+ %>
16
+ <div class="card p-4 space-y-2" data-test="style-type-ladder">
17
+ <h3 class="type-h4 text-heading">Typography</h3>
18
+ <% ladder.each do |role, size, use| %>
19
+ <div class="flex flex-col gap-0.5 min-w-0" data-type-role="<%= role %>">
20
+ <code class="text-xs text-muted shrink-0"><%= role %> · <%= size %></code>
21
+ <span class="<%= role %> text-heading min-w-0"><%= use %></span>
22
+ </div>
23
+ <% end %>
24
+ <hr class="border-subtle">
25
+ <p class="text-body text-sm">Body ink. The quick brown fox jumps over the lazy dog.</p>
26
+ <p class="text-secondary text-sm">Secondary ink. Additional context and metadata.</p>
27
+ <p class="text-muted text-xs">Muted ink. Timestamps, hints and fine print.</p>
28
+ <div class="flex flex-wrap gap-4 pt-1">
29
+ <% [400, 500, 600, 700, 800, 900].each do |weight| %>
30
+ <span class="type-h4 text-heading" style="font-weight: <%= weight %>"><%= weight %></span>
31
+ <% end %>
32
+ </div>
33
+ </div>
@@ -20,6 +20,10 @@
20
20
  specimen drives and why it is not the one the Modals section uses:
21
21
  app/views/style/_host.html.erb.
22
22
 
23
+ HEADINGS wear the type ladder, on trial on this page only (style/_type_ladder): the one
24
+ <h1> is studio/_page_header's, at type-h2; a section is an <h2> at type-h3; a subsection
25
+ is an <h3> at type-h4.
26
+
23
27
  Specimens use ONLY the engine's own classes (engine.css component layer plus
24
28
  engine-motion.css motion/effect/leveling layer), so a consumer that imports
25
29
  the engine's Tailwind entry (host.css: engine.css and engine-motion.css)
@@ -53,7 +57,7 @@
53
57
  ["components", "Components"]
54
58
  ]
55
59
  %>
56
- <div class="max-w-6xl mx-auto px-4 pt-6 pb-16">
60
+ <div class="max-w-6xl mx-auto px-4 pt-6 pb-16" data-type-ladder>
57
61
  <%# The page's title and its section tabs: studio/_page_header, the row of the
58
62
  triple stack (docs/PAGE_HEADER.md). It sits at --z-sticky, above --z-raised,
59
63
  so the live hold button's portal bubbles scroll under it rather than over it.
data/docs/SITE_FOOTER.md CHANGED
@@ -436,7 +436,7 @@ config.site_footer_chat_path = "/chat" # or ->(view) { view.chat_pat
436
436
 
437
437
  | Setting | Meaning | Unset |
438
438
  |---------|---------|-------|
439
- | `site_footer_chat_path` | A path, or a callable taking the view (route helpers do not exist yet in an initializer). It answers nil to show no link on this page | No link |
439
+ | `site_footer_chat_path` | A path, or a callable taking the view (route helpers do not exist yet in an initializer). It answers nil to show no link on this page; one that raises hides the link and reports it (the log on each render, `ErrorLog` once per error class), and the page still renders | No link |
440
440
  | `site_footer_chat_label` | The link's words | "Chat" |
441
441
  | `site_footer_chat_allowed` | A callable taking the viewer: `current_user`, **nil when signed out**, so write `user&.admin?` | `Studio.agent_chat_allowed` (everyone, by default) |
442
442
 
@@ -25,7 +25,8 @@ module Studio
25
25
  # POST that still holds the visitor's anon token sends {email:, anon_token:},
26
26
  # and the hub folds the anonymous thread into the person's. A turn is
27
27
  # {id, role: "user"|"agent", text, created_at, status} with status
28
- # pending|typing|answered|failed.
28
+ # pending|typing|answered|failed|held. held: stored, and no automatic answer is
29
+ # coming (a hub that predates it says failed for the same message).
29
30
  #
30
31
  # `voice` marks a spoken message: {check, score, duration_ms}, what POST
31
32
  # /voice/takes answered for the take (voice_param). NEVER A NAME: the hub
@@ -104,12 +105,13 @@ module Studio
104
105
  "silent" => { "button" => "Hold to talk" },
105
106
  "failed" => { "button" => "Hold to try again" },
106
107
  "denied" => { "button" => "Hold to talk" },
108
+ "tapped" => { "button" => "Hold to talk" },
107
109
  "unsupported" => { "button" => "Hold to talk" }
108
110
  }.freeze
109
111
 
110
112
  # The microphone chip's first words (the engine's say "tap Record").
111
113
  VOICE_CHIP_COPY = {
112
- "prompt" => { "chip" => "Microphone: hold to talk to allow",
114
+ "prompt" => { "chip" => "Microphone: hold to allow",
113
115
  "note" => "Hold the talk button once and allow the microphone. Then you can pick one here." }
114
116
  }.freeze
115
117
 
@@ -0,0 +1,12 @@
1
+ # Vendored icons
2
+
3
+ `lucide/` holds the line icons `studio_icon` renders, copied unchanged from the
4
+ `lucide-static` npm package, version 1.68.0. Lucide is under the ISC license, and its
5
+ Feather-derived icons (`log-out` here) are under MIT. Both texts are in
6
+ [`lucide/LICENSE`](lucide/LICENSE), which ships with the gem.
7
+
8
+ The files are vendored, and render inline, because a strict CSP allows no icon CDN.
9
+
10
+ To add an icon, copy its `.svg` from the same package version, and read it first. Vendor
11
+ only what a view uses. `test/lib/studio_icons_test.rb` holds each file to the shape the
12
+ helper reads.
@@ -0,0 +1,43 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 Lucide Icons and Contributors
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
16
+
17
+ ---
18
+
19
+ The following Lucide icons are derived from the Feather project:
20
+
21
+ airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
22
+
23
+ The MIT License (MIT) (for the icons listed above)
24
+
25
+ Copyright (c) 2013-present Cole Bemis
26
+
27
+ Permission is hereby granted, free of charge, to any person obtaining a copy
28
+ of this software and associated documentation files (the "Software"), to deal
29
+ in the Software without restriction, including without limitation the rights
30
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
31
+ copies of the Software, and to permit persons to whom the Software is
32
+ furnished to do so, subject to the following conditions:
33
+
34
+ The above copyright notice and this permission notice shall be included in all
35
+ copies or substantial portions of the Software.
36
+
37
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
38
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
39
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
40
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
41
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
42
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
43
+ SOFTWARE.
@@ -0,0 +1,15 @@
1
+ <!-- @license lucide-static v1.68.0 - ISC -->
2
+ <svg
3
+ class="lucide lucide-activity"
4
+ xmlns="http://www.w3.org/2000/svg"
5
+ width="24"
6
+ height="24"
7
+ viewBox="0 0 24 24"
8
+ fill="none"
9
+ stroke="currentColor"
10
+ stroke-width="2"
11
+ stroke-linecap="round"
12
+ stroke-linejoin="round"
13
+ >
14
+ <path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" />
15
+ </svg>
@@ -0,0 +1,16 @@
1
+ <!-- @license lucide-static v1.68.0 - ISC -->
2
+ <svg
3
+ class="lucide lucide-book-open"
4
+ xmlns="http://www.w3.org/2000/svg"
5
+ width="24"
6
+ height="24"
7
+ viewBox="0 0 24 24"
8
+ fill="none"
9
+ stroke="currentColor"
10
+ stroke-width="2"
11
+ stroke-linecap="round"
12
+ stroke-linejoin="round"
13
+ >
14
+ <path d="M12 5v16" />
15
+ <path d="M20.001 19A2 2 0 0022 17V5a2 2 0 00-1.999-2L16 3.002A5 5 0 0012 5a5 5 0 00-4-2H4a2 2 0 00-2 2v12a2 2 0 001.999 2H8a5 5 0 014 2 5 5 0 014-2z" />
16
+ </svg>
@@ -0,0 +1,18 @@
1
+ <!-- @license lucide-static v1.68.0 - ISC -->
2
+ <svg
3
+ class="lucide lucide-chart-column"
4
+ xmlns="http://www.w3.org/2000/svg"
5
+ width="24"
6
+ height="24"
7
+ viewBox="0 0 24 24"
8
+ fill="none"
9
+ stroke="currentColor"
10
+ stroke-width="2"
11
+ stroke-linecap="round"
12
+ stroke-linejoin="round"
13
+ >
14
+ <path d="M3 3v16a2 2 0 0 0 2 2h16" />
15
+ <path d="M18 17V9" />
16
+ <path d="M13 17V5" />
17
+ <path d="M8 17v-3" />
18
+ </svg>
@@ -0,0 +1,15 @@
1
+ <!-- @license lucide-static v1.68.0 - ISC -->
2
+ <svg
3
+ class="lucide lucide-diamond"
4
+ xmlns="http://www.w3.org/2000/svg"
5
+ width="24"
6
+ height="24"
7
+ viewBox="0 0 24 24"
8
+ fill="none"
9
+ stroke="currentColor"
10
+ stroke-width="2"
11
+ stroke-linecap="round"
12
+ stroke-linejoin="round"
13
+ >
14
+ <path d="M2.7 10.3a2.41 2.41 0 0 0 0 3.41l7.59 7.59a2.41 2.41 0 0 0 3.41 0l7.59-7.59a2.41 2.41 0 0 0 0-3.41l-7.59-7.59a2.41 2.41 0 0 0-3.41 0Z" />
15
+ </svg>
@@ -0,0 +1,19 @@
1
+ <!-- @license lucide-static v1.68.0 - ISC -->
2
+ <svg
3
+ class="lucide lucide-door-stairwell"
4
+ xmlns="http://www.w3.org/2000/svg"
5
+ width="24"
6
+ height="24"
7
+ viewBox="0 0 24 24"
8
+ fill="none"
9
+ stroke="currentColor"
10
+ stroke-width="2"
11
+ stroke-linecap="round"
12
+ stroke-linejoin="round"
13
+ >
14
+ <path d="M12 17v-3a1 1 0 011-1h6" />
15
+ <path d="M19 17h-9a1 1 0 00-1 1v3" />
16
+ <path d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16" />
17
+ <path d="M19 9h-3a1 1 0 00-1 1v3" />
18
+ <path d="M22 21H2" />
19
+ </svg>
@@ -0,0 +1,17 @@
1
+ <!-- @license lucide-static v1.68.0 - ISC -->
2
+ <svg
3
+ class="lucide lucide-globe"
4
+ xmlns="http://www.w3.org/2000/svg"
5
+ width="24"
6
+ height="24"
7
+ viewBox="0 0 24 24"
8
+ fill="none"
9
+ stroke="currentColor"
10
+ stroke-width="2"
11
+ stroke-linecap="round"
12
+ stroke-linejoin="round"
13
+ >
14
+ <circle cx="12" cy="12" r="10" />
15
+ <path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
16
+ <path d="M2 12h20" />
17
+ </svg>
@@ -0,0 +1,17 @@
1
+ <!-- @license lucide-static v1.68.0 - ISC -->
2
+ <svg
3
+ class="lucide lucide-hammer"
4
+ xmlns="http://www.w3.org/2000/svg"
5
+ width="24"
6
+ height="24"
7
+ viewBox="0 0 24 24"
8
+ fill="none"
9
+ stroke="currentColor"
10
+ stroke-width="2"
11
+ stroke-linecap="round"
12
+ stroke-linejoin="round"
13
+ >
14
+ <path d="m15 12-9.373 9.373a1 1 0 0 1-3.001-3L12 9" />
15
+ <path d="m18 15 4-4" />
16
+ <path d="m21.5 11.5-1.914-1.914A2 2 0 0 1 19 8.172v-.344a2 2 0 0 0-.586-1.414l-1.657-1.657A6 6 0 0 0 12.516 3H9l1.243 1.243A6 6 0 0 1 12 8.485V10l2 2h1.172a2 2 0 0 1 1.414.586L18.5 14.5" />
17
+ </svg>
@@ -0,0 +1,16 @@
1
+ <!-- @license lucide-static v1.68.0 - ISC -->
2
+ <svg
3
+ class="lucide lucide-inbox"
4
+ xmlns="http://www.w3.org/2000/svg"
5
+ width="24"
6
+ height="24"
7
+ viewBox="0 0 24 24"
8
+ fill="none"
9
+ stroke="currentColor"
10
+ stroke-width="2"
11
+ stroke-linecap="round"
12
+ stroke-linejoin="round"
13
+ >
14
+ <polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
15
+ <path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
16
+ </svg>
@@ -0,0 +1,16 @@
1
+ <!-- @license lucide-static v1.68.0 - ISC -->
2
+ <svg
3
+ class="lucide lucide-key-round"
4
+ xmlns="http://www.w3.org/2000/svg"
5
+ width="24"
6
+ height="24"
7
+ viewBox="0 0 24 24"
8
+ fill="none"
9
+ stroke="currentColor"
10
+ stroke-width="2"
11
+ stroke-linecap="round"
12
+ stroke-linejoin="round"
13
+ >
14
+ <path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z" />
15
+ <circle cx="16.5" cy="7.5" r=".5" fill="currentColor" />
16
+ </svg>
@@ -0,0 +1,18 @@
1
+ <!-- @license lucide-static v1.68.0 - ISC -->
2
+ <svg
3
+ class="lucide lucide-layout-grid"
4
+ xmlns="http://www.w3.org/2000/svg"
5
+ width="24"
6
+ height="24"
7
+ viewBox="0 0 24 24"
8
+ fill="none"
9
+ stroke="currentColor"
10
+ stroke-width="2"
11
+ stroke-linecap="round"
12
+ stroke-linejoin="round"
13
+ >
14
+ <rect width="7" height="7" x="3" y="3" rx="1" />
15
+ <rect width="7" height="7" x="14" y="3" rx="1" />
16
+ <rect width="7" height="7" x="14" y="14" rx="1" />
17
+ <rect width="7" height="7" x="3" y="14" rx="1" />
18
+ </svg>