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,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>
@@ -0,0 +1,17 @@
1
+ <!-- @license lucide-static v1.68.0 - ISC -->
2
+ <svg
3
+ class="lucide lucide-log-out"
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="m16 17 5-5-5-5" />
15
+ <path d="M21 12H9" />
16
+ <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
17
+ </svg>
@@ -0,0 +1,15 @@
1
+ <!-- @license lucide-static v1.68.0 - ISC -->
2
+ <svg
3
+ class="lucide lucide-message-circle"
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.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719" />
15
+ </svg>
@@ -0,0 +1,18 @@
1
+ <!-- @license lucide-static v1.68.0 - ISC -->
2
+ <svg
3
+ class="lucide lucide-package"
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="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z" />
15
+ <path d="M12 22V12" />
16
+ <polyline points="3.29 7 12 12 20.71 7" />
17
+ <path d="m7.5 4.27 9 5.15" />
18
+ </svg>
@@ -0,0 +1,19 @@
1
+ <!-- @license lucide-static v1.68.0 - ISC -->
2
+ <svg
3
+ class="lucide lucide-palette"
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 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z" />
15
+ <circle cx="13.5" cy="6.5" r=".5" fill="currentColor" />
16
+ <circle cx="17.5" cy="10.5" r=".5" fill="currentColor" />
17
+ <circle cx="6.5" cy="12.5" r=".5" fill="currentColor" />
18
+ <circle cx="8.5" cy="7.5" r=".5" fill="currentColor" />
19
+ </svg>
@@ -0,0 +1,18 @@
1
+ <!-- @license lucide-static v1.68.0 - ISC -->
2
+ <svg
3
+ class="lucide lucide-plug"
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 22v-5" />
15
+ <path d="M15 8V2" />
16
+ <path d="M17 8a1 1 0 0 1 1 1v4a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1z" />
17
+ <path d="M9 8V2" />
18
+ </svg>
@@ -0,0 +1,18 @@
1
+ <!-- @license lucide-static v1.68.0 - ISC -->
2
+ <svg
3
+ class="lucide lucide-rocket"
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 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" />
15
+ <path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09" />
16
+ <path d="M9 12a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.4 22.4 0 0 1-4 2z" />
17
+ <path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 .05 5 .05" />
18
+ </svg>
@@ -0,0 +1,16 @@
1
+ <!-- @license lucide-static v1.68.0 - ISC -->
2
+ <svg
3
+ class="lucide lucide-user"
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="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
15
+ <circle cx="12" cy="7" r="4" />
16
+ </svg>
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+
5
+ # The engine's one line-icon set: Lucide, vendored under lib/studio/icons/lucide (see the
6
+ # README beside it for the license and how to add one). Pure Ruby, so the unit suite reads
7
+ # it without booting the dummy app. Views call StudioIconHelper#studio_icon.
8
+ #
9
+ # A NAME IS LOOKED UP, NEVER BUILT INTO A PATH. Only a name in the directory listing
10
+ # reaches File.read, so a host passing a bad name gets nil and cannot read another file.
11
+ module Studio
12
+ module Icons
13
+ DIR = File.expand_path("icons/lucide", __dir__)
14
+ # Lucide draws at 2 on a 24 box. At 18px that reads heavy beside 13.5px text, so the
15
+ # set renders thinner, as the sidebar's approved mockup did (2 x 18/24 is 1.5).
16
+ STROKE_WIDTH = 1.6
17
+
18
+ module_function
19
+
20
+ def names
21
+ @names ||= Dir.children(DIR).filter_map { |file| file.delete_suffix(".svg") if file.end_with?(".svg") }.sort.freeze
22
+ end
23
+
24
+ def known?(name)
25
+ names.include?(name.to_s)
26
+ end
27
+
28
+ # The whole <svg>, or nil for a name the set does not hold. Decorative: the row or
29
+ # button beside it carries the words.
30
+ def svg(name, size: 18, css_class: nil)
31
+ return nil unless known?(name)
32
+
33
+ px = Integer(size)
34
+ attributes = [
35
+ %(xmlns="http://www.w3.org/2000/svg"),
36
+ %(width="#{px}" height="#{px}"),
37
+ %(viewBox="0 0 24 24"),
38
+ %(fill="none" stroke="currentColor" stroke-width="#{STROKE_WIDTH}"),
39
+ %(stroke-linecap="round" stroke-linejoin="round"),
40
+ %(aria-hidden="true" focusable="false"),
41
+ (%(class="#{ERB::Util.html_escape(css_class)}") if css_class.to_s != "")
42
+ ].compact.join(" ")
43
+ "<svg #{attributes}>#{shapes(name.to_s)}</svg>"
44
+ end
45
+
46
+ # The drawing inside the file's <svg>, read once per name.
47
+ def shapes(name)
48
+ @shapes ||= {}
49
+ @shapes[name] ||= File.read(File.join(DIR, "#{name}.svg"))[%r{<svg[^>]*>(.*)</svg>}m, 1].to_s.gsub(/\s*\n\s*/, "").freeze
50
+ end
51
+ end
52
+ end
data/lib/studio/loader.rb CHANGED
@@ -27,6 +27,13 @@ module Studio
27
27
  module Loader
28
28
  class InvalidConfig < ArgumentError; end
29
29
 
30
+ # The one pace of a page load, for every app (docs/LOADER.md). A visit under the delay
31
+ # shows no overlay and waits for nothing. An overlay that shows stays the minimum.
32
+ # studio/loader falls back to the same two numbers; test/views/loader_render_test.rb
33
+ # holds them level.
34
+ OVERLAY_DELAY_MS = 350
35
+ OVERLAY_MIN_SHOW_MS = 750
36
+
30
37
  SETTING = "Studio.loader"
31
38
  GENERIC_STYLES = %i[breathe liquid orbit steel].freeze
32
39
  ART_STYLES = { weld_bead: "outline", sparkle: "spark" }.freeze
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "uri"
4
+
3
5
  # Resolves Studio.sidebar_sections — the host's declared link-sidebar data —
4
6
  # for a given view context. Pure Ruby (no Rails dependency) so the unit suite
5
7
  # exercises the resolution rules without booting the dummy app.
@@ -8,7 +10,7 @@
8
10
  # context) for dynamic data: route helpers, logged_in? walls, model-backed
9
11
  # link lists. Each section normalizes to symbol keys:
10
12
  #
11
- # { title: "Site", admin: true, links: [
13
+ # { title: "Site", admin: true, header: true, links: [
12
14
  # { label: "Dashboard", href: "/admin", emoji: "📊",
13
15
  # hover_emoji: "🔬", desc: "Users + logs", target: "_blank" } ] }
14
16
  #
@@ -16,8 +18,13 @@
16
18
  # trigger and panel stay invisible to everyone else even when the host
17
19
  # declares nothing but admin links.
18
20
  #
19
- # A section's title is its group's accessible name; the panel shows no label word. A
20
- # link's desc is optional and shows only on hover or keyboard focus.
21
+ # A section's title is its group's accessible name. By default the panel shows no label
22
+ # word; a section with header: true also shows its title above its rows. A link's desc is
23
+ # optional and shows only on hover or keyboard focus.
24
+ #
25
+ # Under Studio.sidebar_style = :glass a link may also carry icon: "rocket", a Studio::Icons
26
+ # name: the row draws that line icon in place of its emoji, with no hover swap. The emoji
27
+ # stays the fallback for a name the set does not hold.
21
28
  #
22
29
  # THE ENGINE PREPENDS ONE SECTION OF ITS OWN — "You", linking to /profile. The
23
30
  # engine ships that page, so it ships the way in rather than asking five apps to
@@ -25,8 +32,49 @@
25
32
  # gates it carries.
26
33
  module Studio
27
34
  module SidebarSections
35
+ STYLES = %i[default glass].freeze
36
+
28
37
  module_function
29
38
 
39
+ # The value Studio.sidebar_style= stores, or ArgumentError. A misspelt style would
40
+ # otherwise draw the default panel with no word about why.
41
+ def style!(value)
42
+ style = value.respond_to?(:to_sym) ? value.to_sym : value
43
+ return style if STYLES.include?(style)
44
+
45
+ raise ArgumentError, "Studio.sidebar_style must be one of #{STYLES.join(', ')}, got #{value.inspect}"
46
+ end
47
+
48
+ # The href of the row for the page at `path`, or nil. One row at most: the longest
49
+ # match wins, so /build/requests lights App Requests and not Build. A row also owns
50
+ # the pages beneath it (/books/2026 lights Books). "/" only ever matches itself. An absolute href counts only on `host`, so a satellite's /docs
51
+ # never lights here.
52
+ def current_href(sections, path, host: nil)
53
+ return nil if path.to_s.empty?
54
+
55
+ here = path.to_s[/\A[^?#]*/].chomp("/")
56
+ best = Array(sections).flat_map { |section| Array(section[:links]) }.filter_map do |link|
57
+ target = link_path(link[:href], host)
58
+ next if target.nil?
59
+ next unless target == here || (!target.empty? && here.start_with?("#{target}/"))
60
+
61
+ [target.length, link[:href]]
62
+ end.max_by(&:first)
63
+ best&.last
64
+ end
65
+
66
+ # An href's local path without its trailing slash, or nil when it leads elsewhere or
67
+ # to nothing here (another host, a bare #anchor, a mailto:).
68
+ def link_path(href, host)
69
+ uri = URI.parse(href.to_s)
70
+ return nil if uri.path.to_s.empty? || uri.opaque
71
+ return nil if uri.host && uri.host != host
72
+
73
+ uri.path.chomp("/")
74
+ rescue URI::Error
75
+ nil
76
+ end
77
+
30
78
  def resolve(declared, view)
31
79
  sections = declared.respond_to?(:call) ? declared.call(view) : declared
32
80
  admin = view.respond_to?(:admin?) && view.admin?
@@ -60,7 +108,7 @@ module Studio
60
108
  return [] unless profile_link?(view)
61
109
 
62
110
  [{ title: "You", links: [
63
- { label: "Profile", href: view.profile_path, emoji: "👤" }
111
+ { label: "Profile", href: view.profile_path, emoji: "👤", icon: "user" }
64
112
  ] }]
65
113
  end
66
114
 
@@ -386,15 +386,36 @@ module Studio
386
386
  # means the app has no chat, and `allowed` (a callable taking the viewer, nil
387
387
  # when signed out) can hide it, e.g. admins only while testing. A hook that
388
388
  # raises hides the entry and logs; the footer is on every page, and a link
389
- # meant for a few must not show because its gate broke.
389
+ # meant for a few must not show because its gate broke. So does a path that
390
+ # raises: without the path there is no link, and the page still renders.
390
391
  def chat_row(view, path:, label: nil, allowed: nil)
391
- href = path.respond_to?(:call) ? path.call(view) : path
392
+ href = chat_href(view, path)
392
393
  return nil if text(href).nil?
393
394
  return nil unless chat_allowed?(view, allowed)
394
395
 
395
396
  [text(label) || "Chat", href]
396
397
  end
397
398
 
399
+ def chat_href(view, path)
400
+ path.respond_to?(:call) ? path.call(view) : path
401
+ rescue StandardError => error
402
+ SiteFooter.reporter.call("[studio.site_footer] site_footer_chat_path raised #{error.class}: the Chat link is hidden")
403
+ capture_once(error)
404
+ nil
405
+ end
406
+
407
+ # ErrorLog is a row per call, and the footer is on every page: one per error class.
408
+ def capture_once(error)
409
+ key = "site_footer_chat_path #{error.class}"
410
+ return if SiteFooter.reported.key?(key) || SiteFooter.reported.size >= REPORTED_LIMIT
411
+ return unless defined?(::ErrorLog) && ::ErrorLog.respond_to?(:capture!)
412
+
413
+ SiteFooter.reported[key] = true
414
+ ::ErrorLog.capture!(error)
415
+ rescue StandardError
416
+ nil # reporting is best-effort: the footer still renders
417
+ end
418
+
398
419
  def chat_allowed?(view, allowed)
399
420
  return true if allowed.nil?
400
421
 
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.127.0"
2
+ VERSION = "0.128.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -33,6 +33,7 @@ require "studio/js_identifier"
33
33
  require "studio/hold_button"
34
34
  require "studio/partial_block"
35
35
  require "studio/sidebar_sections"
36
+ require "studio/icons"
36
37
  require "studio/site_footer"
37
38
  require "studio/marketing_kit"
38
39
  require "studio/booking"
@@ -177,6 +178,22 @@ module Studio
177
178
  @@page_transition_direction = direction
178
179
  end
179
180
 
181
+ # One of :slide or :unfold. The ONE style this app's pages play, on every page change and
182
+ # every full load. Alex McRitchie's ruling, 2026-11-04: one app, one way, nothing random. The
183
+ # default is :slide, the plainer of the two, so a host that sets only a direction gets one
184
+ # steady move and never a draw. It does nothing without a direction, and nil means the default.
185
+ PAGE_TRANSITION_STYLES = %i[slide unfold].freeze
186
+ mattr_reader :page_transition_style, default: :slide
187
+
188
+ def self.page_transition_style=(value)
189
+ style = value.presence&.to_sym || :slide
190
+ unless PAGE_TRANSITION_STYLES.include?(style)
191
+ raise ArgumentError, "Studio.page_transition_style must be one of " \
192
+ "#{PAGE_TRANSITION_STYLES.join(', ')} (got #{value.inspect})"
193
+ end
194
+ @@page_transition_style = style
195
+ end
196
+
180
197
  # Minimum ms the nav spinner stays visible once shown (_head.html.erb).
181
198
  # Apps wrapping multi-second operations in the spinner (e.g. Solana RPC)
182
199
  # keep the high default to avoid flicker; smooth-load apps typically drop
@@ -259,6 +276,37 @@ module Studio
259
276
  # end
260
277
  mattr_accessor :sidebar_sections, default: []
261
278
 
279
+ # Whether the link sidebar draws its own Geo row for an admin. Default true. False
280
+ # takes the row out of this panel only: components/_geo_signpost stays, so a host
281
+ # that moves Geo elsewhere renders the partial on that page.
282
+ mattr_accessor :sidebar_geo_signpost, default: true
283
+
284
+ # How the link sidebar's panel looks. :default is the full-height drawer every app has
285
+ # today. :glass floats an inset, translucent panel over the page, with line icons
286
+ # (a link's icon:), a marked current page and no separate blurred edge. It is opt-in,
287
+ # so an app moves only when it asks. engine.css, THE GLASS SIDEBAR, holds the look.
288
+ mattr_reader :sidebar_style, default: :default
289
+
290
+ def self.sidebar_style=(value)
291
+ @@sidebar_style = SidebarSections.style!(value)
292
+ end
293
+
294
+ # ---- Surfaces ----
295
+ # How cards look. :glass makes them translucent over a soft ambient page, once the host
296
+ # layout says so on its root: <html data-surface-style="<%= Studio.surface_style %>">.
297
+ # :default is what every app has today. engine.css, THE GLASS SURFACES, holds the look.
298
+ SURFACE_STYLES = %i[default glass].freeze
299
+ mattr_reader :surface_style, default: :default
300
+
301
+ def self.surface_style=(value)
302
+ style = value.respond_to?(:to_sym) ? value.to_sym : value
303
+ unless SURFACE_STYLES.include?(style)
304
+ raise ArgumentError, "Studio.surface_style must be one of #{SURFACE_STYLES.join(', ')}, got #{value.inspect}"
305
+ end
306
+
307
+ @@surface_style = style
308
+ end
309
+
262
310
  # ---- Navbar links ----
263
311
  # Links for the engine navbar's desktop bar and phone row. The default []
264
312
  # renders nothing, so the navbar stays byte-identical until an app opts in.
@@ -338,6 +386,10 @@ module Studio
338
386
  # art: "/brand/loader-sparkle.svg" }
339
387
  mattr_reader :loader, default: Loader.normalize(nil)
340
388
 
389
+ # The page-load overlay reaches every page through layouts/studio/_head, at the one
390
+ # standard pace in Studio::Loader. false opts this app out. docs/LOADER.md.
391
+ mattr_accessor :loader_overlay, default: true
392
+
341
393
  def self.loader=(config)
342
394
  @@loader = Loader.normalize(config)
343
395
  end
@@ -1802,8 +1854,8 @@ module Studio
1802
1854
  # config.site_footer_chat_label = "Chat" # the default
1803
1855
  # config.site_footer_chat_allowed = ->(user) { user&.admin? } # default: Studio.agent_chat_allowed
1804
1856
  #
1805
- # site_footer_chat_allowed takes the viewer, nil when signed out; a hook that
1806
- # raises hides the entry and logs (SiteFooter.chat_row). Unset, the link follows
1857
+ # site_footer_chat_allowed takes the viewer, nil when signed out; a path or a hook
1858
+ # that raises hides the entry and logs (SiteFooter.chat_row). Unset, the link follows
1807
1859
  # Studio.agent_chat_allowed, so the link shows to whom the chat answers.
1808
1860
  mattr_accessor :site_footer_chat_path, default: nil
1809
1861
  mattr_accessor :site_footer_chat_label, default: "Chat"