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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +121 -0
- data/README.md +2 -2
- data/app/controllers/concerns/studio/admin_models.rb +24 -16
- data/app/controllers/concerns/studio/error_handling.rb +13 -0
- data/app/controllers/studio/agent_chats_controller.rb +31 -4
- data/app/controllers/studio/voice_takes_controller.rb +66 -2
- data/app/controllers/style_controller.rb +2 -5
- data/app/controllers/theme_settings_controller.rb +8 -8
- data/app/helpers/studio/agent_chat_helper.rb +37 -0
- data/app/javascript/studio/agent_chat.js +42 -2
- data/app/javascript/studio/agent_chat_voice.js +318 -0
- data/app/javascript/studio/alpine_shims.js +1 -3
- data/app/javascript/studio/controllers/agent_chat_voice_controller.js +17 -0
- data/app/javascript/studio/controllers/style_section_nav_controller.js +80 -0
- data/app/javascript/studio/microphone_chip.js +8 -3
- data/app/javascript/studio/stimulus.js +3 -0
- data/app/javascript/studio/style_guide.js +2 -0
- data/app/javascript/studio/style_section_nav.js +16 -0
- data/app/javascript/studio/theme_editor.js +6 -106
- data/app/javascript/studio/voice_profile.js +11 -1
- data/app/javascript/studio/voice_recorder.js +36 -4
- data/app/models/studio/user_session.rb +76 -0
- data/app/models/theme_setting.rb +3 -0
- data/app/views/studio/admin_models/_sessions_table.html.erb +28 -0
- data/app/views/studio/admin_models/show.html.erb +22 -11
- data/app/views/studio/agent_chat/_chat.html.erb +17 -2
- data/app/views/studio/agent_chat/_form.html.erb +3 -2
- data/app/views/studio/agent_chat/_voice.html.erb +54 -0
- data/app/views/studio/voice/_microphone_help.html.erb +9 -4
- data/app/views/studio/voice/_styles.html.erb +4 -0
- data/app/views/style/_theme.html.erb +75 -12
- data/app/views/style/index.html.erb +28 -30
- data/db/migrate/20261103120000_create_studio_user_sessions.rb +16 -0
- data/docs/SITE_FOOTER.md +33 -0
- data/lib/studio/agent_chat.rb +83 -5
- data/lib/studio/site_footer.rb +45 -2
- data/lib/studio/ui_primitives.rb +4 -2
- data/lib/studio/version.rb +1 -1
- data/lib/studio/voice_transcription.rb +100 -34
- data/lib/studio.rb +81 -8
- metadata +11 -4
- data/app/views/studio/admin_models/index.html.erb +0 -31
- 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
|
|
593
|
-
#
|
|
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
|
|
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
|
|
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.
|
|
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-
|
|
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>
|