studio-engine 0.121.0 → 0.123.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +121 -0
  3. data/README.md +2 -2
  4. data/app/controllers/concerns/studio/admin_models.rb +24 -16
  5. data/app/controllers/concerns/studio/error_handling.rb +13 -0
  6. data/app/controllers/studio/agent_chats_controller.rb +31 -4
  7. data/app/controllers/studio/voice_takes_controller.rb +66 -2
  8. data/app/controllers/style_controller.rb +2 -5
  9. data/app/controllers/theme_settings_controller.rb +8 -8
  10. data/app/helpers/studio/agent_chat_helper.rb +37 -0
  11. data/app/javascript/studio/agent_chat.js +42 -2
  12. data/app/javascript/studio/agent_chat_voice.js +318 -0
  13. data/app/javascript/studio/alpine_shims.js +1 -3
  14. data/app/javascript/studio/controllers/agent_chat_voice_controller.js +17 -0
  15. data/app/javascript/studio/controllers/style_section_nav_controller.js +80 -0
  16. data/app/javascript/studio/microphone_chip.js +8 -3
  17. data/app/javascript/studio/stimulus.js +3 -0
  18. data/app/javascript/studio/style_guide.js +2 -0
  19. data/app/javascript/studio/style_section_nav.js +16 -0
  20. data/app/javascript/studio/theme_editor.js +6 -106
  21. data/app/javascript/studio/voice_profile.js +11 -1
  22. data/app/javascript/studio/voice_recorder.js +36 -4
  23. data/app/models/studio/user_session.rb +76 -0
  24. data/app/models/theme_setting.rb +3 -0
  25. data/app/views/studio/admin_models/_sessions_table.html.erb +28 -0
  26. data/app/views/studio/admin_models/show.html.erb +22 -11
  27. data/app/views/studio/agent_chat/_chat.html.erb +17 -2
  28. data/app/views/studio/agent_chat/_form.html.erb +3 -2
  29. data/app/views/studio/agent_chat/_voice.html.erb +54 -0
  30. data/app/views/studio/voice/_microphone_help.html.erb +9 -4
  31. data/app/views/studio/voice/_styles.html.erb +4 -0
  32. data/app/views/style/_theme.html.erb +75 -12
  33. data/app/views/style/index.html.erb +28 -30
  34. data/db/migrate/20261103120000_create_studio_user_sessions.rb +16 -0
  35. data/docs/SITE_FOOTER.md +33 -0
  36. data/lib/studio/agent_chat.rb +83 -5
  37. data/lib/studio/site_footer.rb +45 -2
  38. data/lib/studio/ui_primitives.rb +4 -2
  39. data/lib/studio/version.rb +1 -1
  40. data/lib/studio/voice_transcription.rb +100 -34
  41. data/lib/studio.rb +81 -8
  42. metadata +11 -4
  43. data/app/views/studio/admin_models/index.html.erb +0 -31
  44. data/app/views/theme_settings/edit.html.erb +0 -304
data/lib/studio.rb CHANGED
@@ -589,10 +589,25 @@ module Studio
589
589
  # VOICE_NO_LIVE_CALLS is armed in every test environment.
590
590
  mattr_accessor :voice_transcriber, default: nil
591
591
 
592
- # The OpenAI key for the real transcriber. nil reads OPENAI_API_KEY. Missing,
593
- # the endpoint answers 503 with a line the page can show; nothing crashes.
592
+ # The OpenAI key for the real transcriber, voice's OWN key. nil reads
593
+ # STUDIO_VOICE_OPENAI_API_KEY, and never the app's shared OPENAI_API_KEY (on
594
+ # the hub that is the image key, with its own budget). Missing, the endpoint
595
+ # answers 503 with a line the page can show; nothing crashes.
594
596
  mattr_accessor :voice_openai_api_key, default: nil
595
597
 
598
+ # One take's whole budget at the vendor, in seconds: the first model AND the
599
+ # fallback share it, so a take never holds a web thread longer than this
600
+ # (Studio::VoiceTranscription::OpenAI). A timeout or a vendor 429 is not
601
+ # retried.
602
+ mattr_accessor :voice_transcribe_deadline, default: 25
603
+
604
+ # POST /voice/takes per signed-in user: at most `to` takes in any `within`
605
+ # seconds (a fixed window), else a JSON 429 with retry_after. Every take is a
606
+ # paid vendor call. `store:` (optional) is the counter's cache; nil uses
607
+ # Rails.cache, or a per-process memory store when Rails.cache is a NullStore.
608
+ # false turns the limit off.
609
+ mattr_accessor :voice_takes_rate_limit, default: { to: 20, within: 600 }
610
+
596
611
  # The speech-to-text models (Voicebox's measured pair) and the language both
597
612
  # are told to hear. A blank fallback turns the one retry off.
598
613
  mattr_accessor :voice_transcribe_model, default: "gpt-4o-transcribe"
@@ -1456,7 +1471,7 @@ module Studio
1456
1471
  mattr_accessor :agent_chat_souls, default: []
1457
1472
  mattr_accessor :agent_chat_hub_url, default: "https://mcritchie.studio"
1458
1473
  # The app's bearer credential for the hub. Leave nil to read
1459
- # ENV["AGENT_CHAT_TOKEN"] (1Password apps.<slug>.agent-chat).
1474
+ # ENV["AGENT_CHAT_TOKEN"] (1Password agent-chat.<slug>.applications).
1460
1475
  mattr_accessor :agent_chat_token, default: nil
1461
1476
  # Which signed-in users the hub may know by email. An email the user only
1462
1477
  # CLAIMS (the profile page saves a new address and clears email_verified_at;
@@ -1471,6 +1486,50 @@ module Studio
1471
1486
  Array(agent_chat_souls).map { |soul| soul.to_s.strip }.reject(&:empty?).uniq
1472
1487
  end
1473
1488
 
1489
+ # Who may use the chat at all: a callable taking the user, nil when signed out.
1490
+ # The server's gate, not only the look: Studio::AgentChatsController answers a
1491
+ # refused viewer 403 on both calls before any hub call, and the chat partial
1492
+ # renders nothing for them. The footer's Chat link follows it too unless
1493
+ # site_footer_chat_allowed is set, so one setting covers both. Default: everyone
1494
+ # (a public chat). While testing:
1495
+ #
1496
+ # config.agent_chat_allowed = ->(user) { user&.admin? }
1497
+ mattr_accessor :agent_chat_allowed, default: ->(_user) { true }
1498
+
1499
+ # The one reading of Studio.agent_chat_allowed. A hook that raises answers false
1500
+ # and logs: `user.admin?` on a signed-out visitor closes the chat, never opens it.
1501
+ def self.agent_chat_allowed?(user)
1502
+ hook = agent_chat_allowed
1503
+ (hook.respond_to?(:call) ? hook.call(user) : hook) ? true : false
1504
+ rescue StandardError => e
1505
+ Rails.logger&.warn("[studio] agent_chat_allowed raised #{e.class}: treated as not allowed") if defined?(Rails.logger)
1506
+ false
1507
+ end
1508
+
1509
+ # Who may talk by voice (docs/AGENT_CHAT.md, "Talking by voice"): a callable
1510
+ # taking the signed-in user. It decides who sees the chat's microphone, who
1511
+ # may post a take to POST /voice/takes (each one is a paid vendor call), and
1512
+ # whether the proxy forwards a message's `voice`. A signed-out visitor is
1513
+ # never asked about: no user, no voice. While testing, an app narrows it:
1514
+ #
1515
+ # config.voice_chat_allowed = ->(user) { user.admin? }
1516
+ #
1517
+ # A user Studio.agent_chat_allowed refuses may not talk either, whatever this says.
1518
+ mattr_accessor :voice_chat_allowed, default: ->(user) { user.present? }
1519
+
1520
+ # The one reading of Studio.voice_chat_allowed. False for no user, and for a user
1521
+ # the chat itself refuses, whatever the hook says. A hook that raises answers false and logs: the safe answer on a
1522
+ # paid, sign-in-only control, and a page never falls over because of it.
1523
+ def self.voice_chat_allowed?(user)
1524
+ return false if user.nil? || !agent_chat_allowed?(user)
1525
+
1526
+ hook = voice_chat_allowed
1527
+ (hook.respond_to?(:call) ? hook.call(user) : hook) ? true : false
1528
+ rescue StandardError => e
1529
+ Rails.logger&.warn("[studio] voice_chat_allowed raised #{e.class}: treated as not allowed") if defined?(Rails.logger)
1530
+ false
1531
+ end
1532
+
1474
1533
  class S3ConfigError < StandardError; end
1475
1534
 
1476
1535
  # Whether to validate the host app's User model at boot. See docs/USER_CONTRACT.md.
@@ -1718,6 +1777,20 @@ module Studio
1718
1777
  SidebarSections.resolve(sidebar_sections, view)
1719
1778
  end
1720
1779
 
1780
+ # The standard Chat entry in the footer's Contact column (docs/SITE_FOOTER.md,
1781
+ # "The Chat link"). OFF until the app names a path: no path, no entry.
1782
+ #
1783
+ # config.site_footer_chat_path = "/chat" # or ->(view) { view.chat_path }
1784
+ # config.site_footer_chat_label = "Chat" # the default
1785
+ # config.site_footer_chat_allowed = ->(user) { user&.admin? } # default: Studio.agent_chat_allowed
1786
+ #
1787
+ # site_footer_chat_allowed takes the viewer, nil when signed out; a hook that
1788
+ # raises hides the entry and logs (SiteFooter.chat_row). Unset, the link follows
1789
+ # Studio.agent_chat_allowed, so the link shows to whom the chat answers.
1790
+ mattr_accessor :site_footer_chat_path, default: nil
1791
+ mattr_accessor :site_footer_chat_label, default: "Chat"
1792
+ mattr_accessor :site_footer_chat_allowed, default: nil
1793
+
1721
1794
  # The site footer's facts resolved for a view (lib/studio/site_footer.rb), or
1722
1795
  # nil when the app turned the footer off (or its own callable answered nil).
1723
1796
  # Unset, the facts are the default footer's. The name defaults to the site
@@ -1729,7 +1802,10 @@ module Studio
1729
1802
 
1730
1803
  declared = site_footer.nil? ? SiteFooter.default_facts(view, routes: host_route_set) : site_footer
1731
1804
  SiteFooter.resolve(declared, view, name: site_identity[:title], logo: logo_for("Footer Logo"),
1732
- booking_path: booking_path_for(view), address: site_footer_address)
1805
+ booking_path: booking_path_for(view), address: site_footer_address,
1806
+ chat: SiteFooter.chat_row(view, path: site_footer_chat_path,
1807
+ label: site_footer_chat_label,
1808
+ allowed: site_footer_chat_allowed.nil? ? agent_chat_allowed : site_footer_chat_allowed))
1733
1809
  end
1734
1810
 
1735
1811
  # The host's route set, for the default footer's legal links. nil outside a
@@ -2036,11 +2112,8 @@ module Studio
2036
2112
  get "schedule", to: "studio/bookings#show", as: :studio_booking
2037
2113
  end
2038
2114
 
2039
- # The living style guide. Canonical at /admin/style (StyleController#index);
2040
- # /admin/design_system redirects here but KEEPS its admin_design_system_path
2041
- # helper so a shipped host sidebar link on the old helper still resolves.
2115
+ # The living style guide (StyleController#index).
2042
2116
  get "admin/style", to: "style#index", as: :admin_style
2043
- get "admin/design_system", to: redirect("/admin/style"), as: :admin_design_system
2044
2117
 
2045
2118
  # The component gallery: Lookbook over the engine's ViewComponent previews.
2046
2119
  # The router refuses anyone but a signed-in admin, so a visitor and a
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.121.0
4
+ version: 0.123.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-11-01 00:00:00.000000000 Z
11
+ date: 2026-11-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -477,6 +477,7 @@ files:
477
477
  - app/controllers/style_controller.rb
478
478
  - app/controllers/theme_settings_controller.rb
479
479
  - app/helpers/studio/admin_models_table_helper.rb
480
+ - app/helpers/studio/agent_chat_helper.rb
480
481
  - app/helpers/studio/at_time_helper.rb
481
482
  - app/helpers/studio/fizz_helper.rb
482
483
  - app/helpers/studio/geo_helper.rb
@@ -489,6 +490,7 @@ files:
489
490
  - app/helpers/studio_sidebar_helper.rb
490
491
  - app/helpers/studio_theme_helper.rb
491
492
  - app/javascript/studio/agent_chat.js
493
+ - app/javascript/studio/agent_chat_voice.js
492
494
  - app/javascript/studio/alpine_scopes.js
493
495
  - app/javascript/studio/alpine_shims.js
494
496
  - app/javascript/studio/alpine_stores.js
@@ -498,6 +500,7 @@ files:
498
500
  - app/javascript/studio/booking.js
499
501
  - app/javascript/studio/confirm_interstitial.js
500
502
  - app/javascript/studio/controllers/agent_chat_controller.js
503
+ - app/javascript/studio/controllers/agent_chat_voice_controller.js
501
504
  - app/javascript/studio/controllers/board_controller.js
502
505
  - app/javascript/studio/controllers/booking_controller.js
503
506
  - app/javascript/studio/controllers/email_banner_scale_controller.js
@@ -512,6 +515,7 @@ files:
512
515
  - app/javascript/studio/controllers/modal_host_controller.js
513
516
  - app/javascript/studio/controllers/nav_collapse_controller.js
514
517
  - app/javascript/studio/controllers/style_modals_controller.js
518
+ - app/javascript/studio/controllers/style_section_nav_controller.js
515
519
  - app/javascript/studio/controllers/survey_controller.js
516
520
  - app/javascript/studio/controllers/toast_controller.js
517
521
  - app/javascript/studio/controllers/voice_profile_controller.js
@@ -548,6 +552,7 @@ files:
548
552
  - app/javascript/studio/studio_confetti.js
549
553
  - app/javascript/studio/style_guide.js
550
554
  - app/javascript/studio/style_modals.js
555
+ - app/javascript/studio/style_section_nav.js
551
556
  - app/javascript/studio/survey.js
552
557
  - app/javascript/studio/theme_editor.js
553
558
  - app/javascript/studio/toast.js
@@ -581,6 +586,7 @@ files:
581
586
  - app/models/studio/model_page.rb
582
587
  - app/models/studio/site_identity.rb
583
588
  - app/models/studio/survey_response.rb
589
+ - app/models/studio/user_session.rb
584
590
  - app/models/theme_setting.rb
585
591
  - app/services/google_oauth_validator.rb
586
592
  - app/services/studio/banner.rb
@@ -631,8 +637,8 @@ files:
631
637
  - app/views/studio/_leveling_activity_assets.html.erb
632
638
  - app/views/studio/_session_stamp.html.erb
633
639
  - app/views/studio/admin_models/_arenas_table.html.erb
640
+ - app/views/studio/admin_models/_sessions_table.html.erb
634
641
  - app/views/studio/admin_models/_teams_table.html.erb
635
- - app/views/studio/admin_models/index.html.erb
636
642
  - app/views/studio/admin_models/show.html.erb
637
643
  - app/views/studio/admin_surveys/index.html.erb
638
644
  - app/views/studio/admin_surveys/show.html.erb
@@ -641,6 +647,7 @@ files:
641
647
  - app/views/studio/agent_chat/_header.html.erb
642
648
  - app/views/studio/agent_chat/_turn.html.erb
643
649
  - app/views/studio/agent_chat/_typing.html.erb
650
+ - app/views/studio/agent_chat/_voice.html.erb
644
651
  - app/views/studio/banners/_app_banner.html.erb
645
652
  - app/views/studio/banners/_button.html.erb
646
653
  - app/views/studio/banners/_dev_mode_button.html.erb
@@ -796,7 +803,6 @@ files:
796
803
  - app/views/style/modals/_unsubscribe_confirm.html.erb
797
804
  - app/views/style/modals/_wallet_connect.html.erb
798
805
  - app/views/style/modals/_wallet_connect_slot.html.erb
799
- - app/views/theme_settings/edit.html.erb
800
806
  - app/views/user_mailer/magic_link.html.erb
801
807
  - app/views/user_mailer/magic_link.text.erb
802
808
  - config/importmap.rb
@@ -821,6 +827,7 @@ files:
821
827
  - db/migrate/20261008120000_add_recording_to_studio_knowledge_docs.rb
822
828
  - db/migrate/20261010120000_add_stored_in_to_image_caches.rb
823
829
  - db/migrate/20261029120000_add_voice_profile_to_users.rb
830
+ - db/migrate/20261103120000_create_studio_user_sessions.rb
824
831
  - docs/BOOKING.md
825
832
  - docs/SITE_FOOTER.md
826
833
  - lib/active_storage/service/studio_trash_s3_service.rb
@@ -1,31 +0,0 @@
1
- <% content_for(:title, "Models - Admin") %>
2
-
3
- <div class="max-w-7xl mx-auto px-4 py-8 space-y-6">
4
- <header class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
5
- <div>
6
- <p class="text-xs text-muted uppercase tracking-wide font-semibold">Admin QA</p>
7
- <h1 class="text-heading text-2xl font-bold mt-1">Models</h1>
8
- <p class="text-muted text-sm mt-1">Ten-row snapshots for records that need quick inspection.</p>
9
- </div>
10
- <%= link_to "Back to dashboard", admin_dashboard_path, class: "btn btn-outline" %>
11
- </header>
12
-
13
- <div class="space-y-5">
14
- <% @sections.each do |section| %>
15
- <section id="model-<%= section.fetch(:key) %>" class="card overflow-hidden">
16
- <div class="px-4 py-4 border-b border-subtle flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
17
- <div>
18
- <h2 class="text-heading text-lg font-semibold"><%= section.fetch(:label) %></h2>
19
- <p class="text-xs text-muted"><%= section.fetch(:description) %></p>
20
- </div>
21
- <div class="flex items-center gap-3">
22
- <span class="text-xs text-muted">Showing <%= section.fetch(:records).size %> of <%= section.fetch(:count) %></span>
23
- <%= link_to "View all", admin_model_path(section.fetch(:key)), class: "text-xs text-primary hover:underline" %>
24
- </div>
25
- </div>
26
-
27
- <%= render admin_models_table_partial(section.fetch(:key)), records: section.fetch(:records) %>
28
- </section>
29
- <% end %>
30
- </div>
31
- </div>
@@ -1,304 +0,0 @@
1
- <%# The editor is studio/theme_editor's themeEditor factory, seeded with the
2
- server-resolved role colours. studio/alpine_shims publishes it. %>
3
- <div class="max-w-5xl mx-auto px-4 py-8" x-data="themeEditor(<%= @theme_setting.resolved_colors.to_json %>)">
4
- <div class="flex items-center justify-between mb-8">
5
- <h1 class="text-2xl font-bold text-heading">Theme</h1>
6
- <div class="flex gap-3">
7
- <%= form_tag("/admin/theme/regenerate", method: :post) do %>
8
- <button type="submit" class="btn btn-outline btn-sm">Regenerate Cache</button>
9
- <% end %>
10
- </div>
11
- </div>
12
-
13
- <%= form_tag("/admin/theme", method: :patch) do %>
14
- <div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
15
- <!-- Color Inputs -->
16
- <div class="card p-6">
17
- <h2 class="text-lg font-bold text-heading mb-6">Role Colors</h2>
18
-
19
- <% ThemeSetting::ROLES.each do |role| %>
20
- <% db_col = ThemeSetting.db_column_for(role) %>
21
- <% db_val = @theme_setting.read_attribute(db_col) %>
22
- <% default_val = @defaults[role] %>
23
- <% current_val = db_val.presence || default_val %>
24
- <div class="flex items-center gap-4 mb-4">
25
- <input type="color"
26
- :value="colors.<%= role %>"
27
- @input="colors.<%= role %> = $event.target.value; updatePreview()"
28
- class="w-10 h-10 rounded cursor-pointer border border-subtle"
29
- style="padding: 1px;">
30
- <div class="flex-1">
31
- <div class="flex items-center gap-2">
32
- <span class="text-heading font-medium text-sm"><%= role.to_s.titleize %></span>
33
- <% if db_val.present? %>
34
- <span class="text-xs text-muted">(custom)</span>
35
- <% end %>
36
- </div>
37
- <input type="text"
38
- name="theme_setting[<%= db_col %>]"
39
- x-model="colors.<%= role %>"
40
- @input="updatePreview()"
41
- @blur="normalizeHex('<%= role %>')"
42
- class="text-xs text-secondary font-mono"
43
- style="background: transparent; border: none; border-bottom: 1px dashed currentColor; padding: 0; width: 5.5rem; outline: none;"
44
- spellcheck="false">
45
- </div>
46
- <% if default_val %>
47
- <button type="button"
48
- @click="resetColor('<%= role %>', '<%= default_val %>')"
49
- class="text-xs text-muted hover:text-heading transition"
50
- title="Reset to default (<%= default_val %>)">
51
- Reset
52
- </button>
53
- <% end %>
54
- </div>
55
- <% end %>
56
-
57
- <div class="flex gap-3 mt-6 pt-6 border-t border-subtle">
58
- <button type="submit" class="btn btn-primary">Save Theme</button>
59
- </div>
60
- </div>
61
-
62
- <!-- Live Preview -->
63
- <div class="space-y-6">
64
- <!-- Dark Preview -->
65
- <div class="rounded-xl border border-subtle overflow-hidden">
66
- <div class="px-4 py-2 bg-surface-alt">
67
- <span class="text-xs font-bold text-secondary uppercase tracking-wider">Dark Mode Preview</span>
68
- </div>
69
- <div class="p-6" :style="darkPreviewStyle()">
70
- <h3 class="font-bold mb-1" :style="{ color: '#ffffff' }">Page Heading</h3>
71
- <p class="text-sm mb-4" :style="{ color: '#e2e8f0' }">Body text on the page surface.</p>
72
- <div class="rounded-lg p-4 mb-4" :style="darkSurfaceStyle()">
73
- <p class="text-sm mb-3" :style="{ color: '#94a3b8' }">Card on surface with secondary text.</p>
74
- <div class="flex gap-2">
75
- <button class="btn btn-sm text-white rounded-lg px-3 py-1.5 text-xs font-bold" :style="{ backgroundColor: colors.primary }">Primary</button>
76
- <button class="btn btn-sm text-white rounded-lg px-3 py-1.5 text-xs font-bold" :style="{ backgroundColor: colors.success || '#367E3A' }">Accent</button>
77
- <button class="btn btn-sm text-white rounded-lg px-3 py-1.5 text-xs font-bold" :style="{ backgroundColor: colors.danger || '#EF4444' }">Danger</button>
78
- </div>
79
- </div>
80
- <div class="flex gap-2">
81
- <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium" :style="{ backgroundColor: colors.primary + '33', color: colors.primary, border: '1px solid ' + colors.primary + '55' }">Badge</span>
82
- <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium" :style="{ backgroundColor: (colors.success || '#367E3A') + '33', color: colors.success || '#367E3A', border: '1px solid ' + (colors.success || '#367E3A') + '55' }">Success</span>
83
- </div>
84
- </div>
85
- </div>
86
-
87
- <!-- Light Preview -->
88
- <div class="rounded-xl border border-subtle overflow-hidden">
89
- <div class="px-4 py-2 bg-surface-alt">
90
- <span class="text-xs font-bold text-secondary uppercase tracking-wider">Light Mode Preview</span>
91
- </div>
92
- <div class="p-6" :style="lightPreviewStyle()">
93
- <h3 class="font-bold mb-1" :style="{ color: '#0f172a' }">Page Heading</h3>
94
- <p class="text-sm mb-4" :style="{ color: '#334155' }">Body text on the page surface.</p>
95
- <div class="rounded-lg p-4 mb-4" :style="lightSurfaceStyle()">
96
- <p class="text-sm mb-3" :style="{ color: '#64748b' }">Card on surface with secondary text.</p>
97
- <div class="flex gap-2">
98
- <button class="btn btn-sm text-white rounded-lg px-3 py-1.5 text-xs font-bold" :style="{ backgroundColor: colors.primary }">Primary</button>
99
- <button class="btn btn-sm text-white rounded-lg px-3 py-1.5 text-xs font-bold" :style="{ backgroundColor: colors.success || '#367E3A' }">Accent</button>
100
- <button class="btn btn-sm text-white rounded-lg px-3 py-1.5 text-xs font-bold" :style="{ backgroundColor: colors.danger || '#EF4444' }">Danger</button>
101
- </div>
102
- </div>
103
- <div class="flex gap-2">
104
- <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium" :style="{ backgroundColor: colors.primary + '33', color: colors.primary, border: '1px solid ' + colors.primary + '55' }">Badge</span>
105
- <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium" :style="{ backgroundColor: (colors.success || '#367E3A') + '33', color: colors.success || '#367E3A', border: '1px solid ' + (colors.success || '#367E3A') + '55' }">Success</span>
106
- </div>
107
- </div>
108
- </div>
109
- </div>
110
- </div>
111
- <% end %>
112
-
113
- <%# ── Styleguide ── %>
114
- <div class="border-t border-subtle mt-12 pt-12 space-y-12">
115
-
116
- <% if Studio.theme_logos.any? %>
117
- <%# ── Logos ── %>
118
- <section>
119
- <h2 class="text-xl font-bold text-heading mb-4">Logos</h2>
120
- <div class="grid grid-cols-2 md:grid-cols-5 gap-4">
121
- <% Studio.theme_logos.each do |logo| %>
122
- <% file = logo.is_a?(Hash) ? logo[:file] : logo %>
123
- <% title = logo.is_a?(Hash) ? logo[:title] : file %>
124
- <div class="card p-4 space-y-3">
125
- <p class="label-upper"><%= title %></p>
126
- <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;">
127
- <img src="/<%= file %>" alt="<%= title %>" class="max-w-16 max-h-16 rounded">
128
- </div>
129
- <p class="text-muted text-xs font-mono"><%= file %></p>
130
- </div>
131
- <% end %>
132
- </div>
133
- </section>
134
- <% end %>
135
-
136
- <%# ── Semantic Tokens ── %>
137
- <section>
138
- <h2 class="text-xl font-bold text-heading mb-4">Semantic Tokens</h2>
139
- <div class="grid grid-cols-2 md:grid-cols-5 gap-3">
140
- <%
141
- semantic_colors = [
142
- { name: "Page", utility: "bg-page", type: :bg },
143
- { name: "Surface", utility: "bg-surface", type: :bg },
144
- { name: "Surface Alt", utility: "bg-surface-alt", type: :bg },
145
- { name: "Inset", utility: "bg-inset", type: :bg },
146
- { name: "Heading", utility: "text-heading", type: :text },
147
- { name: "Body", utility: "text-body", type: :text },
148
- { name: "Secondary", utility: "text-secondary", type: :text },
149
- { name: "Muted", utility: "text-muted", type: :text },
150
- { name: "Border", utility: "border-subtle", type: :border },
151
- { name: "Border Strong", utility: "border-strong", type: :border },
152
- ]
153
- %>
154
- <% semantic_colors.each do |c| %>
155
- <div class="card p-3 flex items-center gap-3">
156
- <% if c[:type] == :bg %>
157
- <div class="w-8 h-8 rounded-full border border-subtle shrink-0 <%= c[:utility] %>"></div>
158
- <% elsif c[:type] == :text %>
159
- <div class="w-8 h-8 rounded-full border border-subtle shrink-0 bg-surface flex items-center justify-center">
160
- <span class="text-sm font-bold <%= c[:utility] %>">A</span>
161
- </div>
162
- <% else %>
163
- <div class="w-8 h-8 rounded-full shrink-0 border-2 <%= c[:utility] %> bg-transparent"></div>
164
- <% end %>
165
- <div class="min-w-0">
166
- <p class="text-heading text-sm font-semibold truncate"><%= c[:name] %></p>
167
- <p class="text-muted text-xs font-mono truncate"><%= c[:utility] %></p>
168
- </div>
169
- </div>
170
- <% end %>
171
- </div>
172
- </section>
173
-
174
- <%# ── Typography ── %>
175
- <section>
176
- <h2 class="text-xl font-bold text-heading mb-4">Typography</h2>
177
- <div class="card p-6 space-y-4">
178
- <p class="text-heading text-3xl font-extrabold">Heading — Montserrat 800</p>
179
- <p class="text-heading text-xl font-bold">Subheading — Montserrat 700</p>
180
- <p class="text-body text-base">Body text — The quick brown fox jumps over the lazy dog. Montserrat 400</p>
181
- <p class="text-secondary text-sm">Secondary text — Additional context and metadata. Montserrat 400</p>
182
- <p class="text-muted text-xs">Muted text — Timestamps, hints, and fine print. Montserrat 400</p>
183
- <hr class="border-subtle">
184
- <div class="flex flex-wrap gap-4">
185
- <% [400, 500, 600, 700, 800, 900].each do |w| %>
186
- <span class="text-heading text-lg" style="font-weight: <%= w %>"><%= w %></span>
187
- <% end %>
188
- </div>
189
- </div>
190
- </section>
191
-
192
- <%# ── Buttons ── %>
193
- <section>
194
- <h2 class="text-xl font-bold text-heading mb-4">Buttons</h2>
195
- <div class="card p-6 space-y-6">
196
- <div>
197
- <p class="label-upper mb-3">Primary</p>
198
- <div class="flex flex-wrap items-center gap-3">
199
- <button type="button" class="btn btn-primary btn-sm">Small</button>
200
- <button type="button" class="btn btn-primary">Medium</button>
201
- <button type="button" class="btn btn-primary btn-lg">Large</button>
202
- </div>
203
- </div>
204
- <div>
205
- <p class="label-upper mb-3">Secondary</p>
206
- <div class="flex flex-wrap items-center gap-3">
207
- <button type="button" class="btn btn-secondary btn-sm">Small</button>
208
- <button type="button" class="btn btn-secondary">Medium</button>
209
- <button type="button" class="btn btn-secondary btn-lg">Large</button>
210
- </div>
211
- </div>
212
- <div>
213
- <p class="label-upper mb-3">Outline</p>
214
- <div class="flex flex-wrap items-center gap-3">
215
- <button type="button" class="btn btn-outline btn-sm">Small</button>
216
- <button type="button" class="btn btn-outline">Medium</button>
217
- <button type="button" class="btn btn-outline btn-lg">Large</button>
218
- </div>
219
- </div>
220
- <div>
221
- <p class="label-upper mb-3">Danger</p>
222
- <div class="flex flex-wrap items-center gap-3">
223
- <button type="button" class="btn btn-danger btn-sm">Small</button>
224
- <button type="button" class="btn btn-danger">Medium</button>
225
- <button type="button" class="btn btn-danger btn-lg">Large</button>
226
- </div>
227
- </div>
228
- <div>
229
- <p class="label-upper mb-3">Google</p>
230
- <div class="flex flex-wrap items-center gap-3">
231
- <button type="button" class="btn btn-google">Sign in with Google</button>
232
- </div>
233
- </div>
234
- <div>
235
- <p class="label-upper mb-3">Disabled</p>
236
- <div class="flex flex-wrap items-center gap-3">
237
- <button type="button" class="btn btn-primary" disabled>Primary</button>
238
- <button type="button" class="btn btn-outline" disabled>Outline</button>
239
- <button type="button" class="btn btn-danger" disabled>Danger</button>
240
- </div>
241
- </div>
242
- </div>
243
- </section>
244
-
245
- <%# ── Components ── %>
246
- <section>
247
- <h2 class="text-xl font-bold text-heading mb-4">Components</h2>
248
- <div class="space-y-6">
249
- <div>
250
- <p class="label-upper mb-3">Cards</p>
251
- <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
252
- <div class="card p-6">
253
- <p class="text-heading font-semibold mb-1">.card</p>
254
- <p class="text-secondary text-sm">Static card with surface background and subtle border.</p>
255
- </div>
256
- <div class="card-hover p-6">
257
- <p class="text-heading font-semibold mb-1">.card-hover</p>
258
- <p class="text-secondary text-sm">Interactive card — hover to see the border glow.</p>
259
- </div>
260
- </div>
261
- </div>
262
-
263
- <div>
264
- <p class="label-upper mb-3">Badges</p>
265
- <div class="flex flex-wrap gap-2">
266
- <span class="badge bg-mint/10 text-mint border-mint/30">Mint</span>
267
- <span class="badge bg-violet/10 text-violet border-violet/30">Violet</span>
268
- <span class="badge bg-red-600/10 text-red-400 border-red-600/30">Red</span>
269
- <span class="badge bg-yellow-500/10 text-yellow-400 border-yellow-500/30">Yellow</span>
270
- <span class="badge bg-surface-alt text-secondary border-subtle">Gray</span>
271
- </div>
272
- </div>
273
-
274
- <div>
275
- <p class="label-upper mb-3">Input Field</p>
276
- <div class="max-w-md">
277
- <input type="text" class="input-field" placeholder="Placeholder text...">
278
- </div>
279
- </div>
280
-
281
- <div>
282
- <p class="label-upper mb-3">Empty State</p>
283
- <div class="empty-state">
284
- <p class="text-secondary">No items found.</p>
285
- <p class="text-muted text-sm mt-1">Try adjusting your search or filters.</p>
286
- </div>
287
- </div>
288
-
289
- <div>
290
- <p class="label-upper mb-3">Label Upper</p>
291
- <span class="label-upper">This is a .label-upper element</span>
292
- </div>
293
-
294
- <div>
295
- <p class="label-upper mb-3">JSON Debug</p>
296
- <div class="json-debug">
297
- <pre class="text-mint text-sm font-mono"><%= JSON.pretty_generate({ id: 1, name: "Example", status: "active", created_at: Time.current.iso8601 }) %></pre>
298
- </div>
299
- </div>
300
- </div>
301
- </section>
302
-
303
- </div>
304
- </div>