studio-engine 0.127.0 → 0.128.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 +116 -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 +519 -37
  6. data/app/controllers/studio/surveys_controller.rb +16 -3
  7. data/app/helpers/studio/loader_helper.rb +2 -2
  8. data/app/helpers/studio_icon_helper.rb +10 -0
  9. data/app/javascript/studio/agent_chat.js +14 -3
  10. data/app/javascript/studio/agent_chat_voice.js +42 -4
  11. data/app/javascript/studio/loader.js +130 -5
  12. data/app/javascript/studio/microphone_chip.js +28 -2
  13. data/app/javascript/studio/page_transitions.js +28 -19
  14. data/app/javascript/studio/survey.js +16 -3
  15. data/app/javascript/studio/theme_editor.js +4 -0
  16. data/app/javascript/studio/voice_recorder.js +18 -7
  17. data/app/models/studio/survey_response.rb +24 -17
  18. data/app/views/components/_geo_signpost.html.erb +1 -0
  19. data/app/views/components/_link_sidebar.html.erb +40 -12
  20. data/app/views/components/_sidebar_panel.html.erb +15 -5
  21. data/app/views/layouts/studio/_head.html.erb +18 -7
  22. data/app/views/layouts/studio/_smooth_load.html.erb +1 -0
  23. data/app/views/layouts/studio/component_preview.html.erb +1 -1
  24. data/app/views/studio/agent_chat/_turn.html.erb +3 -1
  25. data/app/views/studio/agent_chat/_voice.html.erb +28 -13
  26. data/app/views/studio/loader/_overlay.html.erb +4 -4
  27. data/app/views/style/_components.html.erb +1 -1
  28. data/app/views/style/_glass.html.erb +83 -0
  29. data/app/views/style/_host.html.erb +1 -1
  30. data/app/views/style/_modals.html.erb +11 -11
  31. data/app/views/style/_scrims.html.erb +1 -1
  32. data/app/views/style/_site_footer.html.erb +1 -1
  33. data/app/views/style/_tasks.html.erb +1 -1
  34. data/app/views/style/_theme.html.erb +56 -72
  35. data/app/views/style/_tricks.html.erb +13 -11
  36. data/app/views/style/_type_ladder.html.erb +33 -0
  37. data/app/views/style/index.html.erb +5 -1
  38. data/docs/SITE_FOOTER.md +1 -1
  39. data/lib/studio/agent_chat.rb +4 -2
  40. data/lib/studio/icons/README.md +12 -0
  41. data/lib/studio/icons/lucide/LICENSE +43 -0
  42. data/lib/studio/icons/lucide/activity.svg +15 -0
  43. data/lib/studio/icons/lucide/book-open.svg +16 -0
  44. data/lib/studio/icons/lucide/chart-column.svg +18 -0
  45. data/lib/studio/icons/lucide/diamond.svg +15 -0
  46. data/lib/studio/icons/lucide/door-stairwell.svg +19 -0
  47. data/lib/studio/icons/lucide/globe.svg +17 -0
  48. data/lib/studio/icons/lucide/hammer.svg +17 -0
  49. data/lib/studio/icons/lucide/inbox.svg +16 -0
  50. data/lib/studio/icons/lucide/key-round.svg +16 -0
  51. data/lib/studio/icons/lucide/layout-grid.svg +18 -0
  52. data/lib/studio/icons/lucide/log-out.svg +17 -0
  53. data/lib/studio/icons/lucide/message-circle.svg +15 -0
  54. data/lib/studio/icons/lucide/package.svg +18 -0
  55. data/lib/studio/icons/lucide/palette.svg +19 -0
  56. data/lib/studio/icons/lucide/plug.svg +18 -0
  57. data/lib/studio/icons/lucide/rocket.svg +18 -0
  58. data/lib/studio/icons/lucide/user.svg +16 -0
  59. data/lib/studio/icons.rb +52 -0
  60. data/lib/studio/loader.rb +7 -0
  61. data/lib/studio/sidebar_sections.rb +52 -4
  62. data/lib/studio/site_footer.rb +23 -2
  63. data/lib/studio/version.rb +1 -1
  64. data/lib/studio.rb +54 -2
  65. metadata +24 -1
@@ -0,0 +1,83 @@
1
+ <%# The glass surfaces, the glass sidebar and the line icons (Studio.surface_style,
2
+ Studio.sidebar_style, studio_icon). Real classes and the real helper, so a change to any
3
+ of them shows here. The stage has a hard-edged backing because glass needs something
4
+ behind it: on a flat page it looks like a flat tint. Each specimen says whether this app
5
+ has turned its switch on, because the surface rules only apply when it has. %>
6
+ <%
7
+ glass_backing = "rounded-xl p-4 [background-image:repeating-linear-gradient(135deg,rgb(var(--color-primary-rgb)/0.55)_0_14px,transparent_14px_28px)]"
8
+ surface_on = Studio.surface_style == :glass
9
+ sidebar_on = Studio.sidebar_style == :glass
10
+ %>
11
+ <section class="space-y-4" data-test="style-glass">
12
+ <div>
13
+ <h3 class="type-h4 text-heading">Glass</h3>
14
+ <p class="text-sm text-secondary mt-1">Translucent surfaces and the floating sidebar. Each is opt-in, so an app moves only when it asks.</p>
15
+ </div>
16
+ <div class="grid gap-4 md:grid-cols-2">
17
+ <%= render layout: "style/specimen",
18
+ locals: { klass: "Studio.surface_style = :glass",
19
+ usage: %(config.surface_style = :glass, and <html data-surface-style="glass">),
20
+ disabled: !surface_on, disabled_label: "surface style is off on this app" } do %>
21
+ <div class="<%= glass_backing %> w-full space-y-2" data-test="style-glass-surfaces">
22
+ <div class="card p-3 text-sm text-heading">.card, the dense tier: no blur</div>
23
+ <div class="card surface-glass-blur p-3 text-sm text-heading">.card.surface-glass-blur</div>
24
+ </div>
25
+ <% end %>
26
+ <%= render layout: "style/specimen",
27
+ locals: { klass: "Studio.sidebar_style = :glass",
28
+ usage: %(config.sidebar_style = :glass),
29
+ disabled: !sidebar_on, disabled_label: "glass sidebar is off on this app" } do %>
30
+ <div class="<%= glass_backing %> w-full" data-test="style-glass-sidebar">
31
+ <div class="flex flex-col" data-sidebar-glass-shell>
32
+ <nav aria-label="Glass sidebar specimen">
33
+ <div role="group" class="pt-2.5">
34
+ <a href="#tricks" class="group flex items-center" aria-current="page"><span class="flex-shrink-0" data-sidebar-row-icon><%= studio_icon("chart-column") %></span><span class="min-w-0"><span class="block truncate">Current page</span></span></a>
35
+ <a href="#tricks" class="group flex items-center" data-sidebar-row-described><span class="flex-shrink-0" data-sidebar-row-icon><%= studio_icon("rocket") %></span><span class="relative min-w-0 flex-1"><span class="block truncate" data-sidebar-row-label>Hover this row</span><span class="block truncate text-xs text-muted" data-sidebar-row-desc>Its description moves nothing</span></span></a>
36
+ </div>
37
+ </nav>
38
+ </div>
39
+ </div>
40
+ <% if respond_to?(:studio_sidebar?) && studio_sidebar? %>
41
+ <button type="button" class="btn btn-sm btn-outline mt-3" @click="$store.sidebars.linkTreeOpen = true" data-test="style-glass-open">Open the real sidebar</button>
42
+ <% end %>
43
+ <% end %>
44
+ <%= render layout: "style/specimen",
45
+ locals: { klass: "Lit from above, under surface_style = :glass",
46
+ usage: %(.btn-primary, .btn-danger, .btn-neutral, .badge, .input-field),
47
+ disabled: !surface_on, disabled_label: "surface style is off on this app" } do %>
48
+ <div class="w-full overflow-x-auto" data-test="style-lit-states">
49
+ <table class="text-xs text-muted">
50
+ <thead><tr><th></th><% %w[rest hover pressed focus disabled].each do |state| %><th class="font-medium text-left"><%= state %></th><% end %></tr></thead>
51
+ <tbody>
52
+ <% [["btn-primary", "Primary"], ["btn-secondary", "Secondary"], ["btn-success", "Success"], ["btn-danger", "Danger"], ["btn-warning", "Warning"], ["btn-primary btn-sm", "Small"], ["btn-neutral btn-sm", "Neutral"], ["btn-outline btn-sm", "Outline"]].each do |classes, label| %>
53
+ <tr>
54
+ <th class="font-medium text-left pr-2"><%= label %></th>
55
+ <% [nil, "hover", "pressed", "focus"].each do |state| %>
56
+ <td class="p-1.5"><button type="button" class="btn <%= classes %>" tabindex="-1" <%= %(data-lit-state="#{state}").html_safe if state %>>Go</button></td>
57
+ <% end %>
58
+ <td class="p-1.5"><button type="button" class="btn <%= classes %>" disabled>Go</button></td>
59
+ </tr>
60
+ <% end %>
61
+ </tbody>
62
+ </table>
63
+ <div class="flex flex-wrap items-center gap-2 mt-3">
64
+ <span class="badge text-heading border-subtle">Badge</span>
65
+ <span class="badge" style="color: var(--color-success-ink); border-color: var(--color-success)">Live</span>
66
+ <input class="input-field max-w-[12rem]" type="text" value="Input, pressed in" aria-label="Input specimen" readonly>
67
+ </div>
68
+ </div>
69
+ <% end %>
70
+ <%= render layout: "style/specimen",
71
+ locals: { klass: "studio_icon(name, size: 18)",
72
+ usage: %(studio_icon("rocket", size: 22)) } do %>
73
+ <ul class="grid grid-cols-4 gap-3 w-full text-heading" data-test="style-glass-icons">
74
+ <% Studio::Icons.names.each do |name| %>
75
+ <li class="flex flex-col items-center gap-1 min-w-0" title="<%= name %>">
76
+ <%= studio_icon(name, size: 22) %>
77
+ <span class="text-3xs text-muted truncate max-w-full"><%= name %></span>
78
+ </li>
79
+ <% end %>
80
+ </ul>
81
+ <% end %>
82
+ </div>
83
+ </section>
@@ -93,7 +93,7 @@
93
93
  <section id="host-modals" class="space-y-8" x-data="{}"
94
94
  style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)">
95
95
  <div class="space-y-1">
96
- <h2 class="text-2xl font-bold text-heading"><%= Studio.app_name %></h2>
96
+ <h2 class="type-h3 text-heading"><%= Studio.app_name %></h2>
97
97
  <p class="text-muted text-sm max-w-2xl">
98
98
  This app&rsquo;s own modals, contributed by
99
99
  <code class="font-mono text-2xs bg-inset px-1.5 py-0.5 rounded">app/views/style/host/_modals.html.erb</code>.
@@ -155,7 +155,7 @@
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
157
  <div class="space-y-1">
158
- <h2 class="text-2xl font-bold text-heading">Modals</h2>
158
+ <h2 class="type-h3 text-heading">Modals</h2>
159
159
  <p class="text-muted text-sm">
160
160
  The shared modal host and the reusable modal partials, ported from Turf
161
161
  Monster into the engine. Each tile is a <strong>visual representation, not
@@ -461,7 +461,7 @@
461
461
  <%# ===================================================================== %>
462
462
  <section id="modals-auth" class="space-y-5">
463
463
  <div class="space-y-1">
464
- <h3 class="text-xl font-bold text-heading">Auth</h3>
464
+ <h3 class="type-h4 text-heading">Auth</h3>
465
465
  <p class="text-muted text-sm">
466
466
  <strong>The engine ships no sign-in card.</strong> A sign-in modal is a
467
467
  product decision &mdash; which methods, what terms, whose legal copy
@@ -523,7 +523,7 @@
523
523
  cannot show, now that the wallet step lives with the wallet cards. %>
524
524
  <section id="modals-profile" class="space-y-5">
525
525
  <div class="space-y-1">
526
- <h3 class="text-xl font-bold text-heading">Profile</h3>
526
+ <h3 class="type-h4 text-heading">Profile</h3>
527
527
  <p class="text-muted text-sm">
528
528
  Who the account IS &mdash; the name it goes by, the birthday that decides
529
529
  eligibility, and the picture. The first three cards are asked at signup
@@ -711,7 +711,7 @@
711
711
  <section id="modals-profile-leveling" class="space-y-5">
712
712
  <div class="space-y-1">
713
713
  <div class="flex flex-wrap items-center gap-3">
714
- <h3 class="text-xl font-bold text-heading">Profile Leveling</h3>
714
+ <h3 class="type-h4 text-heading">Profile Leveling</h3>
715
715
  <% if leveling_on %>
716
716
  <span class="badge" style="color: var(--color-success-ink); border-color: var(--color-success)">leveling on</span>
717
717
  <% else %>
@@ -865,7 +865,7 @@
865
865
  <section id="modals-web3" class="space-y-5">
866
866
  <div class="space-y-1">
867
867
  <div class="flex flex-wrap items-center gap-3">
868
- <h3 class="text-xl font-bold text-heading">Web3</h3>
868
+ <h3 class="type-h4 text-heading">Web3</h3>
869
869
  <% if web3_on %>
870
870
  <span class="badge" style="color: var(--color-success-ink); border-color: var(--color-success)">enabled on this app</span>
871
871
  <% else %>
@@ -1035,7 +1035,7 @@
1035
1035
  <section id="modals-contest-entry" class="space-y-5">
1036
1036
  <div class="space-y-1">
1037
1037
  <div class="flex flex-wrap items-center gap-3">
1038
- <h3 class="text-xl font-bold text-heading">Contest entry &amp; eligibility</h3>
1038
+ <h3 class="type-h4 text-heading">Contest entry &amp; eligibility</h3>
1039
1039
  <% if age_gate_on %>
1040
1040
  <span class="badge" style="color: var(--color-success-ink); border-color: var(--color-success)">age gate on</span>
1041
1041
  <% else %>
@@ -1133,7 +1133,7 @@
1133
1133
  <%# ===================================================================== %>
1134
1134
  <section id="modals-system-status" class="space-y-5">
1135
1135
  <div class="space-y-1">
1136
- <h3 class="text-xl font-bold text-heading">System &amp; status</h3>
1136
+ <h3 class="type-h4 text-heading">System &amp; status</h3>
1137
1137
  <p class="text-muted text-sm">
1138
1138
  The reusable card blocks under <code class="font-mono text-2xs">studio/modals/blocks/</code> &mdash;
1139
1139
  the studio-canonical processing, success, error, and countdown states.
@@ -1283,7 +1283,7 @@
1283
1283
  <%# ===================================================================== %>
1284
1284
  <section id="modals-templates" class="space-y-5">
1285
1285
  <div class="space-y-1">
1286
- <h3 class="text-xl font-bold text-heading">Templates</h3>
1286
+ <h3 class="type-h4 text-heading">Templates</h3>
1287
1287
  <p class="text-muted text-sm">
1288
1288
  Reusable archetypes under <code class="font-mono text-2xs">studio/modals/templates/</code> &mdash;
1289
1289
  the copy-from reference cards for a new modal (wizard, form, action,
@@ -1318,7 +1318,7 @@
1318
1318
  <section id="modals-rewards" class="space-y-5">
1319
1319
  <div class="space-y-1">
1320
1320
  <div class="flex flex-wrap items-center gap-3">
1321
- <h3 class="text-xl font-bold text-heading">Rewards</h3>
1321
+ <h3 class="type-h4 text-heading">Rewards</h3>
1322
1322
  <% if leveling_on %>
1323
1323
  <span class="badge" style="color: var(--color-success-ink); border-color: var(--color-success)">enabled on this app</span>
1324
1324
  <% else %>
@@ -1395,7 +1395,7 @@
1395
1395
  <%# ===================================================================== %>
1396
1396
  <section id="modals-motion-registry" class="space-y-5">
1397
1397
  <div class="space-y-1">
1398
- <h3 class="text-xl font-bold text-heading">Enter &amp; leave animations</h3>
1398
+ <h3 class="type-h4 text-heading">Enter &amp; leave animations</h3>
1399
1399
  <p class="text-muted text-sm">
1400
1400
  The per-modal entrance/exit registry &mdash;
1401
1401
  <code class="font-mono text-2xs">window.ModalAnimations</code>, published by
@@ -1461,7 +1461,7 @@
1461
1461
  <%# ===================================================================== %>
1462
1462
  <section id="modals-stack-mechanics" class="space-y-5">
1463
1463
  <div class="space-y-1">
1464
- <h3 class="text-xl font-bold text-heading">Stack behaviour</h3>
1464
+ <h3 class="type-h4 text-heading">Stack behaviour</h3>
1465
1465
  <p class="text-muted text-sm">
1466
1466
  The engine modal-stack mechanics, poked at directly on
1467
1467
  <code class="font-mono text-2xs">$store.dsModals</code>:
@@ -37,7 +37,7 @@
37
37
  %>
38
38
  <section id="scrims" class="space-y-5" data-scrim-group>
39
39
  <div class="space-y-1">
40
- <h3 class="text-xl font-bold text-heading">Tints &amp; shades</h3>
40
+ <h3 class="type-h4 text-heading">Tints &amp; shades</h3>
41
41
  <p class="text-muted text-sm max-w-2xl">
42
42
  A <strong>shade</strong> lays the dark base over media and light ink reads on it; a
43
43
  <strong>tint</strong> lays the light base over media and dark ink reads on it.
@@ -35,7 +35,7 @@
35
35
  %>
36
36
  <section id="site-footer" class="space-y-5" style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)">
37
37
  <div class="space-y-1">
38
- <h3 class="text-xl font-bold text-heading">Site footer and booking</h3>
38
+ <h3 class="type-h4 text-heading">Site footer and booking</h3>
39
39
  <p class="text-muted text-sm max-w-2xl">
40
40
  One layout line renders the public site's footer from
41
41
  <code class="font-mono text-2xs">config.site_footer</code>; every part is optional. With
@@ -65,7 +65,7 @@
65
65
  %>
66
66
  <section id="tasks" class="space-y-8" style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)">
67
67
  <div class="space-y-1">
68
- <h2 class="text-2xl font-bold text-heading">Tasks</h2>
68
+ <h2 class="type-h3 text-heading">Tasks</h2>
69
69
  <p class="text-muted text-sm max-w-2xl">
70
70
  The shared board primitive that every board builds from &mdash; columns, cards, the
71
71
  <code class="font-mono text-2xs">stage-*</code> badge palette, and the drag / optimistic
@@ -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,7 +57,7 @@
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>
60
+ <h2 class="type-h3 text-heading">Theme</h2>
64
61
  <p class="text-muted text-sm max-w-2xl">
65
62
  The color foundation. The 7-role system, editable here. Edits preview live; Save writes them
66
63
  and Regenerate clears the theme cache. Both persist <strong>in place</strong> — the page never
@@ -75,25 +72,32 @@
75
72
  </form>
76
73
  </div>
77
74
 
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>
75
+ <%# THE LOGOS, first in the section. A host may supply its own block in
76
+ app/views/style/host/_logos.html.erb, the same seam as host modals: the hub's has
77
+ one-click PNG downloads, which draw through a script only the hub carries. %>
78
+ <% if lookup_context.exists?("logos", ["style/host"], true) %>
79
+ <%= render "style/host/logos" %>
80
+ <% else %>
81
+ <% logos = Studio.respond_to?(:theme_logos) ? Array(Studio.theme_logos) : [] %>
82
+ <% if logos.any? %>
83
+ <div class="space-y-3">
84
+ <p class="label-upper">Logos</p>
85
+ <div class="grid gap-4 grid-cols-2 md:grid-cols-5">
86
+ <% logos.each do |logo| %>
87
+ <% file = logo.is_a?(Hash) ? logo[:file] : logo %>
88
+ <% title = logo.is_a?(Hash) ? logo[:title] : file %>
89
+ <div class="card p-4 space-y-3">
90
+ <p class="text-sm text-heading font-medium"><%= title %></p>
91
+ <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;">
92
+ <img src="/<%= file %>" alt="<%= title %>" class="max-w-16 max-h-16 rounded">
93
+ </div>
94
+ <code class="font-mono text-2xs text-muted break-all"><%= file %></code>
92
95
  </div>
93
- </div>
94
- <% end %>
96
+ <% end %>
97
+ </div>
95
98
  </div>
96
- </div>
99
+ <% end %>
100
+ <% end %>
97
101
 
98
102
  <div class="grid gap-6 lg:grid-cols-2">
99
103
  <%# ---- Role-color editor (posts to /admin/theme, intercepted in place) - %>
@@ -103,7 +107,8 @@
103
107
  <% if csrf_ok %>
104
108
  <input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>" autocomplete="off">
105
109
  <% end %>
106
- <h3 class="text-lg font-bold text-heading">Role colors</h3>
110
+ <h3 class="type-h4 text-heading">Role colors</h3>
111
+ <p class="text-muted text-sm">Every specimen on this page reads these through its CSS variable, so it follows the app and its light or dark mode.</p>
107
112
 
108
113
  <% roles.each do |role| %>
109
114
  <% db_col = db_col_for.call(role) %>
@@ -119,6 +124,7 @@
119
124
  <div class="flex items-center gap-2">
120
125
  <span class="text-heading font-medium text-sm"><%= role.to_s.titleize %></span>
121
126
  <% if has_custom %><span class="text-xs text-muted">(custom)</span><% end %>
127
+ <% if role_vars[role.to_sym] %><code class="font-mono text-2xs text-muted truncate"><%= role_vars[role.to_sym] %></code><% end %>
122
128
  </div>
123
129
  <input type="text"
124
130
  name="theme_setting[<%= db_col %>]"
@@ -135,14 +141,28 @@
135
141
  </div>
136
142
  <% end %>
137
143
 
144
+ <div class="pt-4 border-t border-subtle space-y-2" data-test="style-derived-tokens">
145
+ <p class="label-upper">Derived, read only</p>
146
+ <% derived_tokens.each do |label, var| %>
147
+ <div class="flex items-center gap-4">
148
+ <div class="w-10 h-6 rounded border border-subtle shrink-0" style="background: var(<%= var %>)"></div>
149
+ <span class="text-heading font-medium text-sm"><%= label %></span>
150
+ <code class="font-mono text-2xs text-muted truncate"><%= var %></code>
151
+ </div>
152
+ <% end %>
153
+ </div>
154
+
138
155
  <div class="pt-4 border-t border-subtle">
139
156
  <button type="submit" class="btn btn-primary" :disabled="saving"
140
157
  x-text="saving ? 'Saving…' : 'Save theme'">Save theme</button>
141
158
  </div>
142
159
  </form>
143
160
 
144
- <%# ---- Live dark/light preview (Alpine, updates as you edit) ----------- %>
145
- <div class="space-y-3">
161
+ <%# The half beside the editor: Typography, then the live dark/light preview, which
162
+ appears once a role color has been edited. %>
163
+ <div class="space-y-6 min-w-0">
164
+ <%= render "style/type_ladder" %>
165
+ <div class="space-y-3" x-show="edited" x-cloak data-test="style-theme-preview">
146
166
  <p class="label-upper">Preview</p>
147
167
  <p class="text-muted text-sm">A live dark/light mock — it tracks the editor as you type, before you save.</p>
148
168
  <div class="grid gap-3 sm:grid-cols-2">
@@ -168,28 +188,9 @@
168
188
  </div>
169
189
  </div>
170
190
  </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
191
  </div>
191
- <% end %>
192
-
192
+ </div>
193
+
193
194
  <%# Semantic tokens: the utilities a page paints with, under the role colours. %>
194
195
  <div class="space-y-3">
195
196
  <p class="label-upper">Semantic tokens</p>
@@ -217,21 +218,4 @@
217
218
  </div>
218
219
  </div>
219
220
 
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
221
  </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.