studio-engine 0.4.1
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 +7 -0
- data/CHANGELOG.md +61 -0
- data/Gemfile +18 -0
- data/LICENSE +21 -0
- data/README.md +93 -0
- data/app/controllers/concerns/studio/error_handling.rb +149 -0
- data/app/controllers/error_logs_controller.rb +16 -0
- data/app/controllers/navbar_controller.rb +6 -0
- data/app/controllers/omniauth_callbacks_controller.rb +17 -0
- data/app/controllers/registrations_controller.rb +25 -0
- data/app/controllers/schema_controller.rb +14 -0
- data/app/controllers/sessions_controller.rb +65 -0
- data/app/controllers/theme_settings_controller.rb +35 -0
- data/app/helpers/studio_theme_helper.rb +15 -0
- data/app/jobs/error_log_cleanup_job.rb +8 -0
- data/app/models/concerns/sluggable.rb +17 -0
- data/app/models/error_log.rb +45 -0
- data/app/models/image_cache.rb +11 -0
- data/app/models/theme_setting.rb +30 -0
- data/app/views/components/_admin_dropdown.html.erb +14 -0
- data/app/views/components/_avatar.html.erb +13 -0
- data/app/views/components/_avatar_cropper.html.erb +135 -0
- data/app/views/components/_badge.html.erb +35 -0
- data/app/views/components/_card.html.erb +4 -0
- data/app/views/components/_copy_button.html.erb +10 -0
- data/app/views/components/_empty_state.html.erb +7 -0
- data/app/views/components/_google_logo.html.erb +1 -0
- data/app/views/components/_input.html.erb +13 -0
- data/app/views/components/_json_debug.html.erb +14 -0
- data/app/views/components/_progress_bar.html.erb +9 -0
- data/app/views/components/_theme_toggle.html.erb +10 -0
- data/app/views/components/_theme_toggle_morph.html.erb +15 -0
- data/app/views/components/_user_nav.html.erb +136 -0
- data/app/views/error_logs/index.html.erb +76 -0
- data/app/views/error_logs/show.html.erb +65 -0
- data/app/views/layouts/_navbar.html.erb +83 -0
- data/app/views/layouts/studio/_flash.html.erb +256 -0
- data/app/views/layouts/studio/_head.html.erb +78 -0
- data/app/views/navbar/show.html.erb +147 -0
- data/app/views/registrations/new.html.erb +80 -0
- data/app/views/schema/index.html.erb +85 -0
- data/app/views/sessions/_sso_continue.html.erb +18 -0
- data/app/views/sessions/new.html.erb +79 -0
- data/app/views/theme_settings/edit.html.erb +376 -0
- data/lib/studio/color_scale.rb +80 -0
- data/lib/studio/engine.rb +12 -0
- data/lib/studio/image_cache.rb +152 -0
- data/lib/studio/s3.rb +72 -0
- data/lib/studio/theme_resolver.rb +99 -0
- data/lib/studio/username_generator.rb +21 -0
- data/lib/studio/version.rb +3 -0
- data/lib/studio-engine.rb +5 -0
- data/lib/studio.rb +134 -0
- data/studio-engine.gemspec +30 -0
- data/tailwind/studio.tailwind.config.js +94 -0
- metadata +189 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<section class="mb-6" x-data="{ search: '' }">
|
|
2
|
+
<div class="flex items-center justify-between mb-4">
|
|
3
|
+
<h2 class="text-2xl font-bold text-heading">Database Schema</h2>
|
|
4
|
+
<span class="text-sm text-muted"><%= @tables.size %> tables</span>
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
<div class="mb-6 relative">
|
|
8
|
+
<input type="text" placeholder="Filter tables..." x-model="search"
|
|
9
|
+
@keydown.escape.prevent="search = ''"
|
|
10
|
+
class="input-field pr-10 py-2.5 text-sm placeholder-muted" />
|
|
11
|
+
<button x-show="search.length > 0" x-cloak @click="search = ''"
|
|
12
|
+
class="absolute right-3 top-1/2 -translate-y-1/2 text-muted hover:text-heading transition">
|
|
13
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
14
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
15
|
+
</svg>
|
|
16
|
+
</button>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
20
|
+
<% @tables.each do |table| %>
|
|
21
|
+
<div class="card p-0 overflow-hidden"
|
|
22
|
+
x-show="!search || '<%= table[:name] %>'.includes(search.toLowerCase())" x-cloak>
|
|
23
|
+
<div class="px-4 py-3 border-b border-subtle flex items-center justify-between">
|
|
24
|
+
<h3 class="text-heading font-bold text-sm font-mono"><%= table[:name] %></h3>
|
|
25
|
+
<span class="text-xs text-muted"><%= table[:columns].size %> columns</span>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<div class="overflow-x-auto">
|
|
29
|
+
<table class="w-full text-xs">
|
|
30
|
+
<thead>
|
|
31
|
+
<tr class="text-left text-muted border-b border-subtle">
|
|
32
|
+
<th class="px-4 py-2 font-medium">Column</th>
|
|
33
|
+
<th class="px-4 py-2 font-medium">Type</th>
|
|
34
|
+
<th class="px-4 py-2 font-medium">Details</th>
|
|
35
|
+
</tr>
|
|
36
|
+
</thead>
|
|
37
|
+
<tbody>
|
|
38
|
+
<% table[:columns].each do |col| %>
|
|
39
|
+
<tr class="border-b border-subtle last:border-0 hover:bg-surface-alt transition">
|
|
40
|
+
<td class="px-4 py-1.5">
|
|
41
|
+
<span class="font-semibold text-heading font-mono"><%= col.name %></span>
|
|
42
|
+
<% if col.name == "id" %>
|
|
43
|
+
<span class="ml-1 text-primary font-bold">PK</span>
|
|
44
|
+
<% end %>
|
|
45
|
+
</td>
|
|
46
|
+
<td class="px-4 py-1.5 text-secondary font-mono"><%= col.sql_type %></td>
|
|
47
|
+
<td class="px-4 py-1.5">
|
|
48
|
+
<span class="inline-flex gap-1.5 flex-wrap">
|
|
49
|
+
<% unless col.null %>
|
|
50
|
+
<span class="text-warning font-medium">NOT NULL</span>
|
|
51
|
+
<% end %>
|
|
52
|
+
<% if col.default.present? %>
|
|
53
|
+
<span class="text-muted">default: <span class="text-secondary font-mono"><%= col.default %></span></span>
|
|
54
|
+
<% end %>
|
|
55
|
+
</span>
|
|
56
|
+
</td>
|
|
57
|
+
</tr>
|
|
58
|
+
<% end %>
|
|
59
|
+
</tbody>
|
|
60
|
+
</table>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<% if table[:indexes].any? %>
|
|
64
|
+
<div class="px-4 py-2 border-t border-subtle bg-surface-alt">
|
|
65
|
+
<p class="text-muted text-xs font-medium mb-1">Indexes</p>
|
|
66
|
+
<% table[:indexes].each do |idx| %>
|
|
67
|
+
<div class="flex items-center gap-2 text-xs py-0.5">
|
|
68
|
+
<span class="text-secondary font-mono truncate flex-1"><%= idx.columns.join(", ") %></span>
|
|
69
|
+
<% if idx.unique %>
|
|
70
|
+
<span class="text-primary font-medium shrink-0">UNIQUE</span>
|
|
71
|
+
<% end %>
|
|
72
|
+
</div>
|
|
73
|
+
<% end %>
|
|
74
|
+
</div>
|
|
75
|
+
<% end %>
|
|
76
|
+
</div>
|
|
77
|
+
<% end %>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<template x-if="search && document.querySelectorAll('[x-show]:not([style*=\'display: none\'])').length === 0">
|
|
81
|
+
<div class="empty-state py-12 text-center">
|
|
82
|
+
<p class="text-muted">No tables matching "<span x-text="search"></span>"</p>
|
|
83
|
+
</div>
|
|
84
|
+
</template>
|
|
85
|
+
</section>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<% if sso_user_available? %>
|
|
2
|
+
<div class="mb-6">
|
|
3
|
+
<%= form_with url: sso_continue_path, method: :post do %>
|
|
4
|
+
<button type="submit" class="btn btn-primary btn-lg w-full flex items-center justify-center gap-3">
|
|
5
|
+
<% if sso_hub_logo %>
|
|
6
|
+
<img src="<%= sso_hub_logo %>" alt="<%= sso_source_app %>" width="20" height="20" class="w-5 h-5 object-contain">
|
|
7
|
+
<% end %>
|
|
8
|
+
Continue as <%= sso_display_name %>
|
|
9
|
+
</button>
|
|
10
|
+
<% end %>
|
|
11
|
+
|
|
12
|
+
<div class="flex items-center gap-3 mt-5">
|
|
13
|
+
<div class="flex-1 h-px border-t border-subtle"></div>
|
|
14
|
+
<span class="text-muted text-xs uppercase tracking-wider">or sign in below</span>
|
|
15
|
+
<div class="flex-1 h-px border-t border-subtle"></div>
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
<% end %>
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<%
|
|
2
|
+
has_sso = defined?(sso_user_available?) && sso_user_available?
|
|
3
|
+
logo_path = Studio.logo_for("Auth Logo")
|
|
4
|
+
words = Studio.app_name.split
|
|
5
|
+
last_word = words.pop
|
|
6
|
+
prefix = words.join(" ")
|
|
7
|
+
%>
|
|
8
|
+
|
|
9
|
+
<div class="min-h-[70vh] flex items-center justify-center">
|
|
10
|
+
<div class="w-full max-w-md">
|
|
11
|
+
<div class="text-center mb-8">
|
|
12
|
+
<% if logo_path %>
|
|
13
|
+
<img src="<%= logo_path %>" alt="<%= Studio.app_name %>" class="w-20 h-20 rounded-full shadow-lg mx-auto mb-4">
|
|
14
|
+
<% end %>
|
|
15
|
+
<h1 class="text-3xl font-extrabold text-heading tracking-tight"><%= prefix %> <span class="text-primary"><%= last_word %></span></h1>
|
|
16
|
+
<p class="text-secondary mt-2">Log in to continue</p>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<% if has_sso %>
|
|
20
|
+
<div x-data="{ revealed: false }" class="relative" :class="{ 'select-none': !revealed }">
|
|
21
|
+
<div x-show="!revealed" @click="revealed = true; setTimeout(() => $refs.emailField.focus(), 500)"
|
|
22
|
+
x-transition:leave="transition ease-in duration-500"
|
|
23
|
+
x-transition:leave-start="opacity-100"
|
|
24
|
+
x-transition:leave-end="opacity-0"
|
|
25
|
+
class="absolute inset-0 z-10 rounded-2xl cursor-pointer flex items-center justify-center backdrop-overlay">
|
|
26
|
+
<span class="text-secondary text-xs font-medium uppercase tracking-wider">Click to show other options</span>
|
|
27
|
+
</div>
|
|
28
|
+
<% end %>
|
|
29
|
+
<div class="card p-8 shadow">
|
|
30
|
+
<% if has_sso %>
|
|
31
|
+
<div class="relative" style="z-index: 20;">
|
|
32
|
+
<%= render "sessions/sso_continue" %>
|
|
33
|
+
</div>
|
|
34
|
+
<% else %>
|
|
35
|
+
<%= render "sessions/sso_continue" if defined?(sso_user_available?) && sso_user_available? %>
|
|
36
|
+
<% end %>
|
|
37
|
+
|
|
38
|
+
<%= form_tag login_path, method: :post do %>
|
|
39
|
+
<div class="mb-5">
|
|
40
|
+
<label for="email" class="block text-sm text-secondary mb-2 font-medium">Email</label>
|
|
41
|
+
<input type="email" name="email" id="email" value="<%= params[:email] %>" <%= 'autofocus' unless has_sso %>
|
|
42
|
+
x-ref="emailField"
|
|
43
|
+
class="input-field"
|
|
44
|
+
placeholder="you@example.com">
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<div class="mb-6">
|
|
48
|
+
<label for="password" class="block text-sm text-secondary mb-2 font-medium">Password</label>
|
|
49
|
+
<input type="password" name="password" id="password"
|
|
50
|
+
class="input-field"
|
|
51
|
+
placeholder="Your password">
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<button type="submit" class="btn btn-primary btn-lg w-full">
|
|
55
|
+
Log In
|
|
56
|
+
</button>
|
|
57
|
+
<% end %>
|
|
58
|
+
|
|
59
|
+
<div class="relative my-6">
|
|
60
|
+
<div class="absolute inset-0 flex items-center"><div class="w-full border-t border-subtle"></div></div>
|
|
61
|
+
<div class="relative flex justify-center text-sm"><span class="bg-surface px-3 text-secondary">or</span></div>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<%= button_to "/auth/google_oauth2", method: :post, data: { turbo: false },
|
|
65
|
+
class: "btn btn-google btn-lg w-full gap-3" do %>
|
|
66
|
+
<%= render "components/google_logo" %>
|
|
67
|
+
Sign in with Google
|
|
68
|
+
<% end %>
|
|
69
|
+
|
|
70
|
+
<p class="text-center text-secondary text-sm mt-6">
|
|
71
|
+
Don't have an account?
|
|
72
|
+
<%= link_to "Sign up", signup_path, class: "text-primary hover:text-primary-300 font-medium underline underline-offset-2" %>
|
|
73
|
+
</p>
|
|
74
|
+
</div>
|
|
75
|
+
<% if has_sso %>
|
|
76
|
+
</div>
|
|
77
|
+
<% end %>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
<div class="max-w-5xl mx-auto px-4 py-8" x-data="themeEditor()">
|
|
2
|
+
<div class="flex items-center justify-between mb-8">
|
|
3
|
+
<h1 class="text-2xl font-bold text-heading">Theme</h1>
|
|
4
|
+
<div class="flex gap-3">
|
|
5
|
+
<%= form_tag("/admin/theme/regenerate", method: :post) do %>
|
|
6
|
+
<button type="submit" class="btn btn-outline btn-sm">Regenerate Cache</button>
|
|
7
|
+
<% end %>
|
|
8
|
+
</div>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<%= form_tag("/admin/theme", method: :patch) do %>
|
|
12
|
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
13
|
+
<!-- Color Inputs -->
|
|
14
|
+
<div class="card p-6">
|
|
15
|
+
<h2 class="text-lg font-bold text-heading mb-6">Role Colors</h2>
|
|
16
|
+
|
|
17
|
+
<% ThemeSetting::ROLES.each do |role| %>
|
|
18
|
+
<% db_col = ThemeSetting.db_column_for(role) %>
|
|
19
|
+
<% db_val = @theme_setting.read_attribute(db_col) %>
|
|
20
|
+
<% default_val = @defaults[role] %>
|
|
21
|
+
<% current_val = db_val.presence || default_val %>
|
|
22
|
+
<div class="flex items-center gap-4 mb-4">
|
|
23
|
+
<input type="color"
|
|
24
|
+
:value="colors.<%= role %>"
|
|
25
|
+
@input="colors.<%= role %> = $event.target.value; updatePreview()"
|
|
26
|
+
class="w-10 h-10 rounded cursor-pointer border border-subtle"
|
|
27
|
+
style="padding: 1px;">
|
|
28
|
+
<div class="flex-1">
|
|
29
|
+
<div class="flex items-center gap-2">
|
|
30
|
+
<span class="text-heading font-medium text-sm"><%= role.to_s.titleize %></span>
|
|
31
|
+
<% if db_val.present? %>
|
|
32
|
+
<span class="text-xs text-muted">(custom)</span>
|
|
33
|
+
<% end %>
|
|
34
|
+
</div>
|
|
35
|
+
<input type="text"
|
|
36
|
+
name="theme_setting[<%= db_col %>]"
|
|
37
|
+
x-model="colors.<%= role %>"
|
|
38
|
+
@input="updatePreview()"
|
|
39
|
+
@blur="normalizeHex('<%= role %>')"
|
|
40
|
+
class="text-xs text-secondary font-mono"
|
|
41
|
+
style="background: transparent; border: none; border-bottom: 1px dashed currentColor; padding: 0; width: 5.5rem; outline: none;"
|
|
42
|
+
spellcheck="false">
|
|
43
|
+
</div>
|
|
44
|
+
<% if default_val %>
|
|
45
|
+
<button type="button"
|
|
46
|
+
@click="resetColor('<%= role %>', '<%= default_val %>')"
|
|
47
|
+
class="text-xs text-muted hover:text-heading transition"
|
|
48
|
+
title="Reset to default (<%= default_val %>)">
|
|
49
|
+
Reset
|
|
50
|
+
</button>
|
|
51
|
+
<% end %>
|
|
52
|
+
</div>
|
|
53
|
+
<% end %>
|
|
54
|
+
|
|
55
|
+
<div class="flex gap-3 mt-6 pt-6 border-t border-subtle">
|
|
56
|
+
<button type="submit" class="btn btn-primary">Save Theme</button>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<!-- Live Preview -->
|
|
61
|
+
<div class="space-y-6">
|
|
62
|
+
<!-- Dark Preview -->
|
|
63
|
+
<div class="rounded-xl border border-subtle overflow-hidden">
|
|
64
|
+
<div class="px-4 py-2 bg-surface-alt">
|
|
65
|
+
<span class="text-xs font-bold text-secondary uppercase tracking-wider">Dark Mode Preview</span>
|
|
66
|
+
</div>
|
|
67
|
+
<div class="p-6" :style="darkPreviewStyle()">
|
|
68
|
+
<h3 class="font-bold mb-1" :style="{ color: '#ffffff' }">Page Heading</h3>
|
|
69
|
+
<p class="text-sm mb-4" :style="{ color: '#e2e8f0' }">Body text on the page surface.</p>
|
|
70
|
+
<div class="rounded-lg p-4 mb-4" :style="darkSurfaceStyle()">
|
|
71
|
+
<p class="text-sm mb-3" :style="{ color: '#94a3b8' }">Card on surface with secondary text.</p>
|
|
72
|
+
<div class="flex gap-2">
|
|
73
|
+
<button class="btn btn-sm text-white rounded-lg px-3 py-1.5 text-xs font-bold" :style="{ backgroundColor: colors.primary }">Primary</button>
|
|
74
|
+
<button class="btn btn-sm text-white rounded-lg px-3 py-1.5 text-xs font-bold" :style="{ backgroundColor: colors.success || '#4BAF50' }">Accent</button>
|
|
75
|
+
<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>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
<div class="flex gap-2">
|
|
79
|
+
<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>
|
|
80
|
+
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium" :style="{ backgroundColor: (colors.success || '#4BAF50') + '33', color: colors.success || '#4BAF50', border: '1px solid ' + (colors.success || '#4BAF50') + '55' }">Success</span>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<!-- Light Preview -->
|
|
86
|
+
<div class="rounded-xl border border-subtle overflow-hidden">
|
|
87
|
+
<div class="px-4 py-2 bg-surface-alt">
|
|
88
|
+
<span class="text-xs font-bold text-secondary uppercase tracking-wider">Light Mode Preview</span>
|
|
89
|
+
</div>
|
|
90
|
+
<div class="p-6" :style="lightPreviewStyle()">
|
|
91
|
+
<h3 class="font-bold mb-1" :style="{ color: '#0f172a' }">Page Heading</h3>
|
|
92
|
+
<p class="text-sm mb-4" :style="{ color: '#334155' }">Body text on the page surface.</p>
|
|
93
|
+
<div class="rounded-lg p-4 mb-4" :style="lightSurfaceStyle()">
|
|
94
|
+
<p class="text-sm mb-3" :style="{ color: '#64748b' }">Card on surface with secondary text.</p>
|
|
95
|
+
<div class="flex gap-2">
|
|
96
|
+
<button class="btn btn-sm text-white rounded-lg px-3 py-1.5 text-xs font-bold" :style="{ backgroundColor: colors.primary }">Primary</button>
|
|
97
|
+
<button class="btn btn-sm text-white rounded-lg px-3 py-1.5 text-xs font-bold" :style="{ backgroundColor: colors.success || '#4BAF50' }">Accent</button>
|
|
98
|
+
<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>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
<div class="flex gap-2">
|
|
102
|
+
<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>
|
|
103
|
+
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium" :style="{ backgroundColor: (colors.success || '#4BAF50') + '33', color: colors.success || '#4BAF50', border: '1px solid ' + (colors.success || '#4BAF50') + '55' }">Success</span>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
<% end %>
|
|
110
|
+
|
|
111
|
+
<%# ── Styleguide ── %>
|
|
112
|
+
<div class="border-t border-subtle mt-12 pt-12 space-y-12">
|
|
113
|
+
|
|
114
|
+
<% if Studio.theme_logos.any? %>
|
|
115
|
+
<%# ── Logos ── %>
|
|
116
|
+
<section>
|
|
117
|
+
<h2 class="text-xl font-bold text-heading mb-4">Logos</h2>
|
|
118
|
+
<div class="grid grid-cols-2 md:grid-cols-5 gap-4">
|
|
119
|
+
<% Studio.theme_logos.each do |logo| %>
|
|
120
|
+
<% file = logo.is_a?(Hash) ? logo[:file] : logo %>
|
|
121
|
+
<% title = logo.is_a?(Hash) ? logo[:title] : file %>
|
|
122
|
+
<div class="card p-4 space-y-3">
|
|
123
|
+
<p class="label-upper"><%= title %></p>
|
|
124
|
+
<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;">
|
|
125
|
+
<img src="/<%= file %>" alt="<%= title %>" class="max-w-16 max-h-16 rounded">
|
|
126
|
+
</div>
|
|
127
|
+
<p class="text-muted text-xs font-mono"><%= file %></p>
|
|
128
|
+
</div>
|
|
129
|
+
<% end %>
|
|
130
|
+
</div>
|
|
131
|
+
</section>
|
|
132
|
+
<% end %>
|
|
133
|
+
|
|
134
|
+
<%# ── Semantic Tokens ── %>
|
|
135
|
+
<section>
|
|
136
|
+
<h2 class="text-xl font-bold text-heading mb-4">Semantic Tokens</h2>
|
|
137
|
+
<div class="grid grid-cols-2 md:grid-cols-5 gap-3">
|
|
138
|
+
<%
|
|
139
|
+
semantic_colors = [
|
|
140
|
+
{ name: "Page", utility: "bg-page", type: :bg },
|
|
141
|
+
{ name: "Surface", utility: "bg-surface", type: :bg },
|
|
142
|
+
{ name: "Surface Alt", utility: "bg-surface-alt", type: :bg },
|
|
143
|
+
{ name: "Inset", utility: "bg-inset", type: :bg },
|
|
144
|
+
{ name: "Heading", utility: "text-heading", type: :text },
|
|
145
|
+
{ name: "Body", utility: "text-body", type: :text },
|
|
146
|
+
{ name: "Secondary", utility: "text-secondary", type: :text },
|
|
147
|
+
{ name: "Muted", utility: "text-muted", type: :text },
|
|
148
|
+
{ name: "Border", utility: "border-subtle", type: :border },
|
|
149
|
+
{ name: "Border Strong", utility: "border-strong", type: :border },
|
|
150
|
+
]
|
|
151
|
+
%>
|
|
152
|
+
<% semantic_colors.each do |c| %>
|
|
153
|
+
<div class="card p-3 flex items-center gap-3">
|
|
154
|
+
<% if c[:type] == :bg %>
|
|
155
|
+
<div class="w-8 h-8 rounded-full border border-subtle shrink-0 <%= c[:utility] %>"></div>
|
|
156
|
+
<% elsif c[:type] == :text %>
|
|
157
|
+
<div class="w-8 h-8 rounded-full border border-subtle shrink-0 bg-surface flex items-center justify-center">
|
|
158
|
+
<span class="text-sm font-bold <%= c[:utility] %>">A</span>
|
|
159
|
+
</div>
|
|
160
|
+
<% else %>
|
|
161
|
+
<div class="w-8 h-8 rounded-full shrink-0 border-2 <%= c[:utility] %> bg-transparent"></div>
|
|
162
|
+
<% end %>
|
|
163
|
+
<div class="min-w-0">
|
|
164
|
+
<p class="text-heading text-sm font-semibold truncate"><%= c[:name] %></p>
|
|
165
|
+
<p class="text-muted text-xs font-mono truncate"><%= c[:utility] %></p>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
<% end %>
|
|
169
|
+
</div>
|
|
170
|
+
</section>
|
|
171
|
+
|
|
172
|
+
<%# ── Typography ── %>
|
|
173
|
+
<section>
|
|
174
|
+
<h2 class="text-xl font-bold text-heading mb-4">Typography</h2>
|
|
175
|
+
<div class="card p-6 space-y-4">
|
|
176
|
+
<p class="text-heading text-3xl font-extrabold">Heading — Montserrat 800</p>
|
|
177
|
+
<p class="text-heading text-xl font-bold">Subheading — Montserrat 700</p>
|
|
178
|
+
<p class="text-body text-base">Body text — The quick brown fox jumps over the lazy dog. Montserrat 400</p>
|
|
179
|
+
<p class="text-secondary text-sm">Secondary text — Additional context and metadata. Montserrat 400</p>
|
|
180
|
+
<p class="text-muted text-xs">Muted text — Timestamps, hints, and fine print. Montserrat 400</p>
|
|
181
|
+
<hr class="border-subtle">
|
|
182
|
+
<div class="flex flex-wrap gap-4">
|
|
183
|
+
<% [400, 500, 600, 700, 800, 900].each do |w| %>
|
|
184
|
+
<span class="text-heading text-lg" style="font-weight: <%= w %>"><%= w %></span>
|
|
185
|
+
<% end %>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
</section>
|
|
189
|
+
|
|
190
|
+
<%# ── Buttons ── %>
|
|
191
|
+
<section>
|
|
192
|
+
<h2 class="text-xl font-bold text-heading mb-4">Buttons</h2>
|
|
193
|
+
<div class="card p-6 space-y-6">
|
|
194
|
+
<div>
|
|
195
|
+
<p class="label-upper mb-3">Primary</p>
|
|
196
|
+
<div class="flex flex-wrap items-center gap-3">
|
|
197
|
+
<button type="button" class="btn btn-primary btn-sm">Small</button>
|
|
198
|
+
<button type="button" class="btn btn-primary">Medium</button>
|
|
199
|
+
<button type="button" class="btn btn-primary btn-lg">Large</button>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
<div>
|
|
203
|
+
<p class="label-upper mb-3">Secondary</p>
|
|
204
|
+
<div class="flex flex-wrap items-center gap-3">
|
|
205
|
+
<button type="button" class="btn btn-secondary btn-sm">Small</button>
|
|
206
|
+
<button type="button" class="btn btn-secondary">Medium</button>
|
|
207
|
+
<button type="button" class="btn btn-secondary btn-lg">Large</button>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
<div>
|
|
211
|
+
<p class="label-upper mb-3">Outline</p>
|
|
212
|
+
<div class="flex flex-wrap items-center gap-3">
|
|
213
|
+
<button type="button" class="btn btn-outline btn-sm">Small</button>
|
|
214
|
+
<button type="button" class="btn btn-outline">Medium</button>
|
|
215
|
+
<button type="button" class="btn btn-outline btn-lg">Large</button>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
<div>
|
|
219
|
+
<p class="label-upper mb-3">Danger</p>
|
|
220
|
+
<div class="flex flex-wrap items-center gap-3">
|
|
221
|
+
<button type="button" class="btn btn-danger btn-sm">Small</button>
|
|
222
|
+
<button type="button" class="btn btn-danger">Medium</button>
|
|
223
|
+
<button type="button" class="btn btn-danger btn-lg">Large</button>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
<div>
|
|
227
|
+
<p class="label-upper mb-3">Google</p>
|
|
228
|
+
<div class="flex flex-wrap items-center gap-3">
|
|
229
|
+
<button type="button" class="btn btn-google">Sign in with Google</button>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
<div>
|
|
233
|
+
<p class="label-upper mb-3">Disabled</p>
|
|
234
|
+
<div class="flex flex-wrap items-center gap-3">
|
|
235
|
+
<button type="button" class="btn btn-primary" disabled>Primary</button>
|
|
236
|
+
<button type="button" class="btn btn-outline" disabled>Outline</button>
|
|
237
|
+
<button type="button" class="btn btn-danger" disabled>Danger</button>
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
</section>
|
|
242
|
+
|
|
243
|
+
<%# ── Components ── %>
|
|
244
|
+
<section>
|
|
245
|
+
<h2 class="text-xl font-bold text-heading mb-4">Components</h2>
|
|
246
|
+
<div class="space-y-6">
|
|
247
|
+
<div>
|
|
248
|
+
<p class="label-upper mb-3">Cards</p>
|
|
249
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
250
|
+
<div class="card p-6">
|
|
251
|
+
<p class="text-heading font-semibold mb-1">.card</p>
|
|
252
|
+
<p class="text-secondary text-sm">Static card with surface background and subtle border.</p>
|
|
253
|
+
</div>
|
|
254
|
+
<div class="card-hover p-6">
|
|
255
|
+
<p class="text-heading font-semibold mb-1">.card-hover</p>
|
|
256
|
+
<p class="text-secondary text-sm">Interactive card — hover to see the border glow.</p>
|
|
257
|
+
</div>
|
|
258
|
+
</div>
|
|
259
|
+
</div>
|
|
260
|
+
|
|
261
|
+
<div>
|
|
262
|
+
<p class="label-upper mb-3">Badges</p>
|
|
263
|
+
<div class="flex flex-wrap gap-2">
|
|
264
|
+
<span class="badge bg-mint/10 text-mint border-mint/30">Mint</span>
|
|
265
|
+
<span class="badge bg-violet/10 text-violet border-violet/30">Violet</span>
|
|
266
|
+
<span class="badge bg-red-600/10 text-red-400 border-red-600/30">Red</span>
|
|
267
|
+
<span class="badge bg-yellow-500/10 text-yellow-400 border-yellow-500/30">Yellow</span>
|
|
268
|
+
<span class="badge bg-surface-alt text-secondary border-subtle">Gray</span>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
<div>
|
|
273
|
+
<p class="label-upper mb-3">Input Field</p>
|
|
274
|
+
<div class="max-w-md">
|
|
275
|
+
<input type="text" class="input-field" placeholder="Placeholder text...">
|
|
276
|
+
</div>
|
|
277
|
+
</div>
|
|
278
|
+
|
|
279
|
+
<div>
|
|
280
|
+
<p class="label-upper mb-3">Empty State</p>
|
|
281
|
+
<div class="empty-state">
|
|
282
|
+
<p class="text-secondary">No items found.</p>
|
|
283
|
+
<p class="text-muted text-sm mt-1">Try adjusting your search or filters.</p>
|
|
284
|
+
</div>
|
|
285
|
+
</div>
|
|
286
|
+
|
|
287
|
+
<div>
|
|
288
|
+
<p class="label-upper mb-3">Label Upper</p>
|
|
289
|
+
<span class="label-upper">This is a .label-upper element</span>
|
|
290
|
+
</div>
|
|
291
|
+
|
|
292
|
+
<div>
|
|
293
|
+
<p class="label-upper mb-3">JSON Debug</p>
|
|
294
|
+
<div class="json-debug">
|
|
295
|
+
<pre class="text-mint text-sm font-mono"><%= JSON.pretty_generate({ id: 1, name: "Example", status: "active", created_at: Time.current.iso8601 }) %></pre>
|
|
296
|
+
</div>
|
|
297
|
+
</div>
|
|
298
|
+
</div>
|
|
299
|
+
</section>
|
|
300
|
+
|
|
301
|
+
</div>
|
|
302
|
+
</div>
|
|
303
|
+
|
|
304
|
+
<script>
|
|
305
|
+
function themeEditor() {
|
|
306
|
+
return {
|
|
307
|
+
colors: {
|
|
308
|
+
primary: '<%= @theme_setting.resolved_colors[:primary] %>',
|
|
309
|
+
success: '<%= @theme_setting.resolved_colors[:success] %>',
|
|
310
|
+
accent: '<%= @theme_setting.resolved_colors[:accent] %>',
|
|
311
|
+
warning: '<%= @theme_setting.resolved_colors[:warning] %>',
|
|
312
|
+
danger: '<%= @theme_setting.resolved_colors[:danger] %>',
|
|
313
|
+
dark: '<%= @theme_setting.resolved_colors[:dark] %>',
|
|
314
|
+
light: '<%= @theme_setting.resolved_colors[:light] %>'
|
|
315
|
+
},
|
|
316
|
+
|
|
317
|
+
resetColor(role, defaultVal) {
|
|
318
|
+
this.colors[role] = defaultVal;
|
|
319
|
+
this.updatePreview();
|
|
320
|
+
},
|
|
321
|
+
|
|
322
|
+
normalizeHex(role) {
|
|
323
|
+
let val = this.colors[role].trim();
|
|
324
|
+
if (!val.startsWith('#')) val = '#' + val;
|
|
325
|
+
if (/^#[0-9a-fA-F]{3}$/.test(val)) {
|
|
326
|
+
val = '#' + val[1] + val[1] + val[2] + val[2] + val[3] + val[3];
|
|
327
|
+
}
|
|
328
|
+
if (/^#[0-9a-fA-F]{6}$/.test(val)) {
|
|
329
|
+
this.colors[role] = val;
|
|
330
|
+
}
|
|
331
|
+
this.updatePreview();
|
|
332
|
+
},
|
|
333
|
+
|
|
334
|
+
updatePreview() {
|
|
335
|
+
// Update live CSS vars on the document for immediate feedback
|
|
336
|
+
document.documentElement.style.setProperty('--color-cta', this.colors.primary);
|
|
337
|
+
document.documentElement.style.setProperty('--color-success', this.colors.success);
|
|
338
|
+
document.documentElement.style.setProperty('--color-warning', this.colors.warning || '#FF7C47');
|
|
339
|
+
document.documentElement.style.setProperty('--color-danger', this.colors.danger || '#EF4444');
|
|
340
|
+
},
|
|
341
|
+
|
|
342
|
+
darkPreviewStyle() {
|
|
343
|
+
return { backgroundColor: this.colors.dark, borderRadius: '0' };
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
darkSurfaceStyle() {
|
|
347
|
+
return {
|
|
348
|
+
backgroundColor: this.lightenColor(this.colors.dark, 0.3),
|
|
349
|
+
border: '1px solid rgba(255,255,255,0.1)'
|
|
350
|
+
};
|
|
351
|
+
},
|
|
352
|
+
|
|
353
|
+
lightPreviewStyle() {
|
|
354
|
+
return { backgroundColor: this.colors.light, borderRadius: '0' };
|
|
355
|
+
},
|
|
356
|
+
|
|
357
|
+
lightSurfaceStyle() {
|
|
358
|
+
return {
|
|
359
|
+
backgroundColor: '#ffffff',
|
|
360
|
+
border: '1px solid #e2e8f0'
|
|
361
|
+
};
|
|
362
|
+
},
|
|
363
|
+
|
|
364
|
+
lightenColor(hex, amount) {
|
|
365
|
+
hex = hex.replace('#', '');
|
|
366
|
+
const r = parseInt(hex.substring(0, 2), 16);
|
|
367
|
+
const g = parseInt(hex.substring(2, 4), 16);
|
|
368
|
+
const b = parseInt(hex.substring(4, 6), 16);
|
|
369
|
+
const nr = Math.round(r + (255 - r) * amount);
|
|
370
|
+
const ng = Math.round(g + (255 - g) * amount);
|
|
371
|
+
const nb = Math.round(b + (255 - b) * amount);
|
|
372
|
+
return '#' + [nr, ng, nb].map(c => c.toString(16).padStart(2, '0')).join('');
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
</script>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
module Studio
|
|
2
|
+
module ColorScale
|
|
3
|
+
# Mix ratios: how much white (lighter) or black (darker) to mix
|
|
4
|
+
LIGHT_RATIOS = {
|
|
5
|
+
50 => 0.95,
|
|
6
|
+
100 => 0.85,
|
|
7
|
+
200 => 0.70,
|
|
8
|
+
300 => 0.50,
|
|
9
|
+
400 => 0.30
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
DARK_RATIOS = {
|
|
13
|
+
600 => 0.15,
|
|
14
|
+
700 => 0.30,
|
|
15
|
+
800 => 0.45,
|
|
16
|
+
900 => 0.60
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
# Generate a full 50-900 shade scale from a base hex color.
|
|
20
|
+
# 500 = base color, lighter shades mix toward white, darker toward black.
|
|
21
|
+
def self.generate(hex)
|
|
22
|
+
r, g, b = hex_to_rgb(hex)
|
|
23
|
+
scale = { 500 => hex.upcase }
|
|
24
|
+
|
|
25
|
+
LIGHT_RATIOS.each do |shade, ratio|
|
|
26
|
+
scale[shade] = rgb_to_hex(
|
|
27
|
+
(r + (255 - r) * ratio).round,
|
|
28
|
+
(g + (255 - g) * ratio).round,
|
|
29
|
+
(b + (255 - b) * ratio).round
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
DARK_RATIOS.each do |shade, ratio|
|
|
34
|
+
scale[shade] = rgb_to_hex(
|
|
35
|
+
(r * (1 - ratio)).round,
|
|
36
|
+
(g * (1 - ratio)).round,
|
|
37
|
+
(b * (1 - ratio)).round
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
scale.sort.to_h
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.lighten(hex, amount)
|
|
45
|
+
r, g, b = hex_to_rgb(hex)
|
|
46
|
+
rgb_to_hex(
|
|
47
|
+
(r + (255 - r) * amount).round,
|
|
48
|
+
(g + (255 - g) * amount).round,
|
|
49
|
+
(b + (255 - b) * amount).round
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.darken(hex, amount)
|
|
54
|
+
r, g, b = hex_to_rgb(hex)
|
|
55
|
+
rgb_to_hex(
|
|
56
|
+
(r * (1 - amount)).round,
|
|
57
|
+
(g * (1 - amount)).round,
|
|
58
|
+
(b * (1 - amount)).round
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.hex_to_rgb(hex)
|
|
63
|
+
hex = hex.delete("#")
|
|
64
|
+
[
|
|
65
|
+
hex[0..1].to_i(16),
|
|
66
|
+
hex[2..3].to_i(16),
|
|
67
|
+
hex[4..5].to_i(16)
|
|
68
|
+
]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.rgb_to_hex(r, g, b)
|
|
72
|
+
"#%02X%02X%02X" % [r.clamp(0, 255), g.clamp(0, 255), b.clamp(0, 255)]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.with_opacity(hex, opacity)
|
|
76
|
+
r, g, b = hex_to_rgb(hex)
|
|
77
|
+
"rgba(#{r},#{g},#{b},#{opacity})"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module Studio
|
|
2
|
+
class Engine < ::Rails::Engine
|
|
3
|
+
config.after_initialize do
|
|
4
|
+
# Validate the host app's User model satisfies the engine's contract.
|
|
5
|
+
# See docs/USER_CONTRACT.md. Opt out with Studio.validate_user_contract = false.
|
|
6
|
+
if defined?(::User) && ::User.is_a?(Class) &&
|
|
7
|
+
(!defined?(::ActiveRecord::Base) || ::User.ancestors.include?(::ActiveRecord::Base))
|
|
8
|
+
Studio.validate_user_contract!(::User)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|