summoner-engine 0.1.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 (35) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +205 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/images/summoner/favicon.ico +0 -0
  6. data/app/assets/stylesheets/summoner/application.css +15 -0
  7. data/app/controllers/summoner/application_controller.rb +7 -0
  8. data/app/controllers/summoner/features_controller.rb +40 -0
  9. data/app/controllers/summoner/overrides_controller.rb +64 -0
  10. data/app/helpers/summoner/application_helper.rb +4 -0
  11. data/app/jobs/summoner/application_job.rb +4 -0
  12. data/app/mailers/summoner/application_mailer.rb +6 -0
  13. data/app/models/summoner/application_record.rb +5 -0
  14. data/app/models/summoner/feature.rb +61 -0
  15. data/app/models/summoner/feature_override.rb +22 -0
  16. data/app/views/layouts/summoner/application.html.erb +139 -0
  17. data/app/views/summoner/features/edit.html.erb +38 -0
  18. data/app/views/summoner/features/index.html.erb +55 -0
  19. data/app/views/summoner/features/show.html.erb +120 -0
  20. data/app/views/summoner/overrides/edit.html.erb +37 -0
  21. data/config/routes.rb +7 -0
  22. data/lib/generators/summoner/install/install_generator.rb +53 -0
  23. data/lib/generators/summoner/install/templates/create_summoner_tables.rb.erb +28 -0
  24. data/lib/generators/summoner/install/templates/features.yml +26 -0
  25. data/lib/generators/summoner/install/templates/summoner.rb +10 -0
  26. data/lib/summoner/configuration.rb +20 -0
  27. data/lib/summoner/engine.rb +9 -0
  28. data/lib/summoner/entity.rb +49 -0
  29. data/lib/summoner/fetcher.rb +66 -0
  30. data/lib/summoner/loader.rb +0 -0
  31. data/lib/summoner/sync.rb +102 -0
  32. data/lib/summoner/version.rb +3 -0
  33. data/lib/summoner.rb +11 -0
  34. data/lib/tasks/summoner_tasks.rake +20 -0
  35. metadata +108 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0c140e7b6dcffcd46213c17ea06f93723b115aae006686c64de4cda0e66d0824
4
+ data.tar.gz: bf77ad299888e02d1e2e9712249776eb6ae6b17d01157f3b20be024350886306
5
+ SHA512:
6
+ metadata.gz: 85eba4331932b10f9bf10aaf3eeeb48f8e12083145c4f247ef8f4e92f475a3112d3da3837863146dac9e2ed24f01bbe4a9a813bbfcc1d99d2ab7178740d4fa2f
7
+ data.tar.gz: 84e1a26a354c4e6cba7507d61b22a02567b960a8b934e992f362947c6b4cc372083d3ec3bbc51f83c900075397b1f0ff5f5e18e5b462ebf76fc72d945ea68351
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Felipe Rodrigues
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,205 @@
1
+ # Summoner
2
+ An advanced, flexible, and powerful Feature Toggle, Dynamic Configuration, and Attribute-Based Access Control (ABAC) engine for Ruby on Rails.
3
+
4
+ Summoner goes way beyond simple booleans. It allows you to manage global or **Entity-specific** configurations (e.g., `User`, `Account`), perform gradual rollouts based on attributes (like `role` or `plan`), attach editable descriptions to each feature, and create surgical exceptions (_overrides_) directly through an elegant Web UI. All wrapped in a **lightning-fast Caching layer** to protect your database.
5
+
6
+ ## Table of Contents
7
+ - [Key Features](#key-features)
8
+ - [Installation](#installation)
9
+ - [Configuration](#configuration)
10
+ - [The Web Dashboard](#the-web-dashboard)
11
+ - [Usage](#usage)
12
+ - [1. Defining your Source of Truth (`features.yml`)](#1-defining-your-source-of-truth-featuresyml)
13
+ - [2. Setting up your Entities](#2-setting-up-your-entities)
14
+ - [3. Checking Features in your Code](#3-checking-features-in-your-code)
15
+ - [Targeted Overrides and Rollouts](#targeted-overrides-and-rollouts)
16
+ - [Contributing](#contributing)
17
+ - [License](#license)
18
+
19
+ ## Key Features
20
+ - **Dynamic Entity Injection**: Features become native methods on your models (e.g., `user.new_dashboard?`).
21
+ - **Attribute-Based Access Control (ABAC)**: Automatically evaluate feature access by matching permission arrays against entity attributes (e.g., checking if a user's role is in the allowed list).
22
+ - **Instant Global Rollouts**: Use the wildcard `["*"]` to release a feature to everyone in seconds, without touching the codebase.
23
+ - **Granular Overrides**: The default rule might be X, but for User ID 42, the rule is Y. Summoner manages these exceptions flawlessly.
24
+ - **Strong Typing**: Native support for complex data types (`boolean`, `integer`, `float`, `json`, `string`).
25
+ - **YAML Synchronization**: Keep a `features.yml` file as your single source of truth and sync it to the database on every deploy. YAML changes update the tracked defaults and descriptions, while manual dashboard edits remain in place until the YAML changes again.
26
+ - **Built-in Web Dashboard**: A mountable Rails Engine for you and your team to manage everything visually.
27
+
28
+ ## Installation
29
+ Add this line to your application's `Gemfile`:
30
+
31
+ ```ruby
32
+ gem "summoner"
33
+ ```
34
+
35
+ Install the gem and run the installation generator:
36
+
37
+ ```bash
38
+ bundle install
39
+ rails generate summoner:install
40
+ ```
41
+
42
+ The generator will create the database migrations, an initializer, and your `config/features.yml` template. Then, build the tables:
43
+
44
+ ```bash
45
+ rails db:migrate
46
+ ```
47
+
48
+ ## Configuration
49
+ You can customize the engine's behavior, especially the built-in Cache, in `config/initializers/summoner.rb`:
50
+
51
+ ```ruby
52
+ Summoner.configure do |config|
53
+ # Enables or disables the use of Rails.cache (Default: true)
54
+ config.cache_enabled = true
55
+
56
+ # A safe namespace to prevent key collisions in Redis/Memcached
57
+ config.cache_namespace = 'summoner'
58
+
59
+ # Defines expiration time for the cache (Default: 1.hour)
60
+ config.cache_expires_in = 1.hour
61
+ end
62
+ ```
63
+
64
+ ### The Web Dashboard
65
+ To manage your features and overrides visually, mount the UI inside your `config/routes.rb`:
66
+
67
+ ```ruby
68
+ Rails.application.routes.draw do
69
+ # ... your application routes ...
70
+ mount Summoner::Engine => "/summoner"
71
+ end
72
+ ```
73
+
74
+ Now visit `http://localhost:3000/summoner` to see the magic happen!
75
+
76
+ <img width="1912" height="573" alt="image" src="https://github.com/user-attachments/assets/2ee2e48b-98c9-4d53-9dcb-f0b2b28e1993" />
77
+
78
+ ## Usage
79
+ Summoner's true power lies in its organization by **Namespaces (Entities)** and **Data Types**.
80
+
81
+ ### 1. Defining your Source of Truth (`features.yml`)
82
+ Declare your application's base behavior in your `config/features.yml`. The structure uses the Entity name as the root key. Each feature can define a `default` value and an optional `description`:
83
+
84
+ ```yaml
85
+ # =========================================================================
86
+ # Whenever you update this file, run in your terminal: rails summoner:sync
87
+ # =========================================================================
88
+
89
+ user:
90
+ # Example 1: Simple Boolean injected into the model
91
+ "new_dashboard?":
92
+ default: false
93
+ description: Enables the new dashboard experience for users.
94
+
95
+ # Example 2: ABAC (Attribute-Based Access Control)
96
+ # Checks if the User's `role` method matches the values in the Array
97
+ "can_access_admin?":
98
+ default: ["admin", "manager"]
99
+ match_attribute: "role"
100
+ description: Allow admins and managers to access the admin area.
101
+
102
+ # Example 3: Global Release with Wildcard ("*")
103
+ # Everyone gets access, ignoring the `role` attribute entirely!
104
+ "can_access_beta?":
105
+ default: ["*"]
106
+ match_attribute: "role"
107
+
108
+ # Example 4: Dynamic Values (Integer, String, JSON)
109
+ max_items_per_page:
110
+ default: 20
111
+ description: Maximum number of items shown per page.
112
+
113
+ system:
114
+ # Features that do not belong to a specific entity
115
+ maintenance_mode:
116
+ default: false
117
+ description: Toggle the maintenance page for all users.
118
+
119
+ app:
120
+ # Global application configurations
121
+ api_timeout:
122
+ default: 30
123
+ description: Request timeout in seconds for external API calls.
124
+ ```
125
+
126
+ Sync these definitions with your database by running:
127
+
128
+ ```bash
129
+ rails summoner:sync
130
+ ```
131
+
132
+ If you edit a feature through the dashboard, the manual change stays active until the same feature changes in `features.yml` again. At that point, Summoner updates the tracked YAML value and YAML description back to match the file.
133
+
134
+ ### 2. Setting up your Entities
135
+ To allow Summoner to inject these configurations directly into your classes (like your `User` model), simply include the core module:
136
+
137
+ ```ruby
138
+ class User < ApplicationRecord
139
+ include Summoner::Entity
140
+ end
141
+ ```
142
+
143
+ ### 3. Checking Features in your Code
144
+ Summoner exposes an elegant, fluent API with automatic caching out of the box.
145
+
146
+ **A. Checking Boolean Toggles and ABAC:**
147
+ Keys ending with a `?` in your YAML are converted into direct query methods on your entity:
148
+
149
+ ```ruby
150
+ user = User.first
151
+
152
+ # Respects the YAML default or a database Override
153
+ if user.new_dashboard?
154
+ render "dashboards/v2"
155
+ else
156
+ render "dashboards/v1"
157
+ end
158
+
159
+ # Automatically evaluates the ABAC array against user.role!
160
+ user.can_access_admin? # => true (if the role is "admin")
161
+ ```
162
+
163
+ **B. Getting Complex Typed Values:**
164
+ To retrieve integers, strings, or JSON, just call the exact key name:
165
+
166
+ ```ruby
167
+ # Returns 20 (or the specific overridden value for this user)
168
+ limit = user.max_items_per_page
169
+
170
+ Product.limit(limit)
171
+ ```
172
+
173
+ **C. Global Features (Without Entities):**
174
+ Not every feature is tied to a user or an account. You can create arbitrary namespaces in your `features.yml` (like `system:`, `app:`, or `global:`) to group application-wide settings.
175
+
176
+ Since these don't belong to an Active Record model, you can query them directly through the `Summoner` module using the `"namespace.feature_key"` format:
177
+
178
+ ```ruby
179
+ # Checking a global boolean toggle
180
+ if Summoner.active?("system.maintenance_mode")
181
+ redirect_to maintenance_path
182
+ end
183
+
184
+ # Getting a global typed value
185
+ api_timeout = Summoner.get("app.api_timeout")
186
+ ```
187
+
188
+ ## Targeted Overrides and Rollouts
189
+ Your YAML defines the "General Rule," but real-world apps require exceptions. Through the Web Dashboard, you can create Overrides for specific instances.
190
+
191
+ **Common Use Cases:**
192
+ - **Beta Testing**: The `user.new_dashboard?` feature is `false` for everyone, but you create an Override set to `true` strictly for User ID: `1` and User ID: `42`.
193
+ - **Tenant Customization**: The `max_items_per_page` limit is `20`, but you sold a VIP plan to User ID: `99` and created an Override with the value `100` just for them.
194
+ - **ABAC Bypass**: `can_access_admin?` only allows `["admin"]`, but you want to grant temporary access to a specific regular user.
195
+
196
+ Summoner's Evaluation Engine always respects the following priority hierarchy:
197
+ **Entity-specific Override > ABAC / Wildcard Rule > Global Default Value.**
198
+
199
+ <img width="1914" height="990" alt="image" src="https://github.com/user-attachments/assets/70a7452a-01b8-4737-a7e8-82a2f9ccbd41" />
200
+
201
+ ## Contributing
202
+ Bug reports and pull requests are warmly welcome on GitHub at https://github.com/feliperodrigs1/summoner.
203
+
204
+ ## License
205
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
@@ -0,0 +1,15 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
+ * files in this directory. Styles in this file should be added after the last require_* statement.
11
+ * It is generally better to create a new file per style scope.
12
+ *
13
+ *= require_tree .
14
+ *= require_self
15
+ */
@@ -0,0 +1,7 @@
1
+ module Summoner
2
+ class ApplicationController < ActionController::Base
3
+ protect_from_forgery with: :exception
4
+
5
+ layout 'summoner/application'
6
+ end
7
+ end
@@ -0,0 +1,40 @@
1
+ module Summoner
2
+ class FeaturesController < ApplicationController
3
+ before_action :set_feature, only: %i[show edit update]
4
+
5
+ def index
6
+ @features = Summoner::Feature.all.order(:namespace, :name)
7
+ end
8
+
9
+ def show
10
+ @feature = Summoner::Feature.find(params[:id])
11
+ @overrides = Summoner::FeatureOverride.where(feature_key: @feature.key)
12
+ end
13
+
14
+ def edit; end
15
+
16
+ def update
17
+ raw_value = params[:feature][:default_value]
18
+ description = params[:feature][:description]
19
+
20
+ unless @feature.valid_value?(raw_value)
21
+ flash.now[:alert] = "Invalid value for type #{@feature.value_type}."
22
+ return render :edit
23
+ end
24
+
25
+ parsed_value = @feature.cast_value(params[:feature][:default_value])
26
+
27
+ if @feature.update(default_value: parsed_value, description: description)
28
+ redirect_to feature_path(@feature), notice: 'Feature updated successfully.'
29
+ else
30
+ render :edit
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def set_feature
37
+ @feature = Summoner::Feature.find(params[:id])
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,64 @@
1
+ module Summoner
2
+ class OverridesController < ApplicationController
3
+ before_action :set_feature
4
+ before_action :set_override, only: [:edit, :update, :destroy]
5
+ before_action :validate_type, only: [:create, :update]
6
+
7
+ def create
8
+ @override = Summoner::FeatureOverride.new(override_params)
9
+
10
+ @override.feature_key = @feature.key
11
+ @override.flaggable_type = @feature.namespace.classify
12
+ @override.value = @feature.cast_value(params[:feature_override][:value])
13
+
14
+ if @override.save
15
+ redirect_to feature_path(@feature), notice: 'Override created successfully!'
16
+ else
17
+ redirect_to feature_path(@feature), alert: "Error: #{@override.errors.full_messages.to_sentence}"
18
+ end
19
+ end
20
+
21
+ def edit; end
22
+
23
+ def update
24
+ parsed_value = @feature.cast_value(params[:feature_override][:value])
25
+
26
+ if @override.update(value: parsed_value)
27
+ redirect_to feature_path(@feature), notice: 'Override updated successfully!'
28
+ else
29
+ render :edit
30
+ end
31
+ end
32
+
33
+ def destroy
34
+ @override.destroy
35
+ redirect_to feature_path(@feature), notice: 'Override removed!'
36
+ end
37
+
38
+ private
39
+
40
+ def set_feature
41
+ @feature = Summoner::Feature.find(params[:feature_id])
42
+ end
43
+
44
+ def set_override
45
+ @override = Summoner::FeatureOverride.find(params[:id])
46
+ end
47
+
48
+ def validate_type
49
+ value = params[:feature_override][:value]
50
+
51
+ unless @feature.valid_value?(value)
52
+ if action_name == 'update'
53
+ redirect_to edit_feature_override_path(@feature, @override), alert: "Invalid value for type #{@feature.value_type}."
54
+ else
55
+ redirect_to feature_path(@feature), alert: "Invalid value for type #{@feature.value_type}."
56
+ end
57
+ end
58
+ end
59
+
60
+ def override_params
61
+ params.require(:feature_override).permit(:flaggable_id)
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,4 @@
1
+ module Summoner
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Summoner
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,6 @@
1
+ module Summoner
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ module Summoner
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,61 @@
1
+ module Summoner
2
+ class Feature < ApplicationRecord
3
+ self.table_name = "summoner_features"
4
+
5
+ validates :key, presence: true, uniqueness: true
6
+ validates :namespace, presence: true
7
+ validates :name, presence: true
8
+ validates :value_type, presence: true
9
+
10
+ after_commit :clear_cache
11
+
12
+ def cast_value(raw_value)
13
+ case value_type
14
+ when 'boolean'
15
+ raw_value.to_s.strip.downcase == 'true'
16
+ when 'integer'
17
+ raw_value.to_i
18
+ when 'float'
19
+ raw_value.to_f
20
+ when 'json'
21
+ raw_value.is_a?(String) ? JSON.parse(raw_value) : raw_value
22
+ else
23
+ raw_value.to_s
24
+ end
25
+ rescue
26
+ raw_value
27
+ end
28
+
29
+ def valid_value?(raw_value)
30
+ str_val = raw_value.to_s.strip
31
+
32
+ case value_type
33
+ when 'boolean'
34
+ str_val.downcase.in?(['true', 'false'])
35
+ when 'integer'
36
+ str_val.match?(/\A-?\d+\z/)
37
+ when 'float'
38
+ str_val.match?(/\A-?\d+(\.\d+)?\z/)
39
+ when 'json'
40
+ begin
41
+ JSON.parse(str_val)
42
+ true
43
+ rescue JSON::ParserError
44
+ false
45
+ end
46
+ else
47
+ true
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def clear_cache
54
+ return unless Summoner.configuration.cache_enabled
55
+
56
+ base_key = "#{Summoner.configuration.cache_namespace}:#{key}"
57
+
58
+ Rails.cache.delete_matched(/#{Regexp.escape(base_key)}/)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,22 @@
1
+ module Summoner
2
+ class FeatureOverride < ApplicationRecord
3
+ self.table_name = 'summoner_feature_overrides'
4
+
5
+ belongs_to :flaggable, polymorphic: true, optional: true
6
+
7
+ validates :feature_key, presence: true
8
+ validates :feature_key, uniqueness: { scope: [:flaggable_id, :flaggable_type] }
9
+
10
+ after_commit :clear_cache
11
+
12
+ private
13
+
14
+ def clear_cache
15
+ return unless Summoner.configuration.cache_enabled
16
+
17
+ cache_key = "#{Summoner.configuration.cache_namespace}:#{feature_key}:#{flaggable_type.underscore}:#{flaggable_id}"
18
+
19
+ Rails.cache.delete(cache_key)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,139 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Summoner Dashboard</title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <%= csp_meta_tag %>
8
+ <link rel="icon" type="image/x-icon" href="<%= image_path('summoner/favicon.ico') %>" />
9
+
10
+ <style>
11
+ :root {
12
+ --bg-main: #f3f4f6;
13
+ --bg-card: #ffffff;
14
+ --text-main: #111827;
15
+ --text-muted: #6b7280;
16
+ --border-color: #e5e7eb;
17
+ --nav-bg: #aa526b;
18
+ --nav-text: #ffffff;
19
+ --btn-primary: #2596be;
20
+ --btn-primary-hover: #1e7a9b;
21
+ --btn-danger: #ef4444;
22
+ --btn-danger-hover: #dc2626;
23
+ --btn-text: #ffffff;
24
+ --code-bg: #e0e7ff;
25
+ --code-text: #4f46e5;
26
+ --badge-active-bg: #d1fae5;
27
+ --badge-active-text: #065f46;
28
+ --badge-inactive-bg: #fee2e2;
29
+ --badge-inactive-text: #991b1b;
30
+ --input-bg: #ffffff;
31
+ }
32
+
33
+ @media (prefers-color-scheme: dark) {
34
+ :root {
35
+ --bg-main: #111111;
36
+ --bg-card: #1c1c1e;
37
+ --text-main: #f4f4f5;
38
+ --text-muted: #a1a1aa;
39
+ --border-color: #2c2c2e;
40
+ --nav-bg: #1c1c1e;
41
+ --nav-text: #aa526b;
42
+ --btn-primary: #2596be;
43
+ --btn-primary-hover: #48a5c8;
44
+ --btn-danger: #dc2626;
45
+ --btn-danger-hover: #ef4444;
46
+ --btn-text: #ffffff;
47
+ --code-bg: #282836;
48
+ --code-text: #818cf8;
49
+ --badge-active-bg: #064e3b;
50
+ --badge-active-text: #34d399;
51
+ --badge-inactive-bg: #7f1d1d;
52
+ --badge-inactive-text: #fca5a5;
53
+ --input-bg: #111111;
54
+ }
55
+ }
56
+
57
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background: var(--bg-main); color: var(--text-main); margin: 0; padding: 0; }
58
+
59
+ .navbar { background: var(--nav-bg); color: var(--nav-text); padding: 1rem 0; border-bottom: 1px solid var(--border-color); }
60
+ .navbar .container { display: flex; align-items: center; margin: 0 auto; padding-top: 0; padding-bottom: 0; }
61
+ .navbar h1 { margin: 0; font-size: 1.5rem; font-weight: 600; letter-spacing: -0.025em; }
62
+
63
+ .container { max-width: 1100px; margin: 2rem auto; padding: 0 1.5rem; }
64
+
65
+ .card { background: var(--bg-card); border-radius: 8px; border: 1px solid var(--border-color); box-shadow: 0 1px 3px rgba(0,0,0,0.05); overflow: hidden; margin-bottom: 2rem; }
66
+ .card-header { padding: 1rem 1.5rem; border-bottom: 1px solid var(--border-color); background: var(--bg-card); }
67
+ .card-header h2, .card-header h3 { margin: 0; font-size: 1.125rem; font-weight: 600; }
68
+ .card-body { padding: 1.25rem 1.5rem; }
69
+
70
+ table { width: 100%; border-collapse: collapse; text-align: left; }
71
+ th { background: var(--bg-main); color: var(--text-muted); font-weight: 600; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; padding: 0.75rem 1.5rem; border-bottom: 1px solid var(--border-color); border-top: 1px solid var(--border-color); }
72
+ td { padding: 1rem 1.5rem; border-bottom: 1px solid var(--border-color); color: var(--text-main); vertical-align: middle; }
73
+ tr:last-child td { border-bottom: none; }
74
+
75
+ code { background: var(--code-bg); color: var(--code-text); padding: 0.2rem 0.4rem; border-radius: 4px; font-size: 0.85em; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
76
+
77
+ .badge { display: inline-flex; align-items: center; padding: 0.25rem 0.625rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; }
78
+ .badge.active { background: var(--badge-active-bg); color: var(--badge-active-text); }
79
+ .badge.inactive { background: var(--badge-inactive-bg); color: var(--badge-inactive-text); }
80
+
81
+ a { color: var(--btn-primary); text-decoration: none; }
82
+ a:hover { text-decoration: underline; }
83
+
84
+ .btn { display: inline-flex; align-items: center; justify-content: center; padding: 0.4rem 1rem; border-radius: 6px; font-size: 0.875rem; font-weight: 500; border: 1px solid transparent; cursor: pointer; transition: all 0.15s; text-decoration: none; line-height: 1.25rem; }
85
+ .btn:hover { text-decoration: none; }
86
+ .btn-sm { padding: 0.25rem 0.625rem; font-size: 0.75rem; }
87
+ .btn-primary { background: var(--btn-primary); color: var(--btn-text); }
88
+ .btn-primary:hover { background: var(--btn-primary-hover); color: var(--btn-text); }
89
+ .btn-danger { background: var(--btn-danger); color: var(--btn-text); }
90
+ .btn-danger:hover { background: var(--btn-danger-hover); color: var(--btn-text); }
91
+ .btn-outline { background: transparent; border-color: var(--border-color); color: var(--text-main); }
92
+ .btn-outline:hover { background: var(--bg-main); color: var(--text-main); }
93
+
94
+ .form-group { margin-bottom: 1rem; }
95
+ .form-label { display: block; font-size: 0.875rem; font-weight: 500; color: var(--text-main); margin-bottom: 0.35rem; }
96
+ .form-control { display: block; width: 100%; padding: 0.4rem 0.6rem; font-size: 0.875rem; line-height: 1.5rem; color: var(--text-main); background-color: var(--input-bg); border: 1px solid var(--border-color); border-radius: 6px; box-sizing: border-box; transition: border-color 0.15s ease-in-out; }
97
+ .form-control:focus { border-color: var(--btn-primary); outline: 0; box-shadow: 0 0 0 1px var(--btn-primary); }
98
+ .form-control[readonly] { background-color: var(--bg-main); cursor: not-allowed; color: var(--text-muted); }
99
+
100
+ .d-flex { display: flex; }
101
+ .gap-2 { gap: 0.5rem; }
102
+ .gap-3 { gap: 1rem; }
103
+ .align-items-center { align-items: center; }
104
+ .align-items-end { align-items: flex-end; }
105
+ .justify-content-between { justify-content: space-between; }
106
+ .flex-wrap { flex-wrap: wrap; }
107
+
108
+ .text-center { text-align: center; }
109
+ .text-muted { color: var(--text-muted); }
110
+ .mb-0 { margin-bottom: 0; }
111
+ .mb-3 { margin-bottom: 1rem; }
112
+ .mb-4 { margin-bottom: 1.5rem; }
113
+
114
+ .grid-details { display: grid; grid-template-columns: minmax(130px, max-content) 1fr; gap: 0.75rem 1rem; align-items: baseline; }
115
+ .grid-details > span:nth-child(odd) { font-weight: 500; color: var(--text-muted); font-size: 0.875rem; }
116
+
117
+ .flash { padding: 1rem; margin-bottom: 1.5rem; border-radius: 6px; font-weight: 500; font-size: 0.875rem; }
118
+ .flash.notice { background: var(--badge-active-bg); color: var(--badge-active-text); border: 1px solid var(--badge-active-text); }
119
+ .flash.alert { background: var(--badge-inactive-bg); color: var(--badge-inactive-text); border: 1px solid var(--badge-inactive-text); }
120
+ </style>
121
+ </head>
122
+ <body>
123
+ <div class="navbar">
124
+ <div class="container">
125
+ <h1>Summoner</h1>
126
+ </div>
127
+ </div>
128
+
129
+ <div class="container">
130
+ <% flash.each do |key, message| %>
131
+ <div class="flash <%= key %>">
132
+ <%= message %>
133
+ </div>
134
+ <% end %>
135
+
136
+ <%= yield %>
137
+ </div>
138
+ </body>
139
+ </html>
@@ -0,0 +1,38 @@
1
+ <div class="mb-4">
2
+ <%= link_to "← Back to Feature", feature_path(@feature), class: "btn btn-outline btn-sm", style: "text-decoration: none;" %>
3
+ </div>
4
+
5
+ <div class="card" style="max-width: 600px;">
6
+ <div class="card-header">
7
+ <h2 class="mb-0">Edit Default Value</h2>
8
+ </div>
9
+
10
+ <div class="card-body">
11
+ <div class="grid-details mb-4">
12
+ <span>Feature:</span> <code><%= @feature.key %></code>
13
+ <span>Expected Type:</span> <code><%= @feature.value_type %></code>
14
+ <span>Description:</span>
15
+ <span><%= @feature.description.presence || "No description set" %></span>
16
+
17
+ <% if @feature.match_attribute.present? %>
18
+ <span>Matches Attribute:</span> <code style="background: #fef08a; color: #854d0e;"><%= @feature.match_attribute %></code>
19
+ <% end %>
20
+ </div>
21
+
22
+ <hr style="border: 0; border-top: 1px solid var(--border-color); margin: 1.5rem 0;">
23
+
24
+ <%= form_with model: @feature, local: true do |f| %>
25
+ <div class="form-group mb-4">
26
+ <%= f.label :default_value, "New Default Value", class: "form-label" %>
27
+ <%= f.text_field :default_value, value: @feature.default_value.is_a?(String) ? @feature.default_value : @feature.default_value.to_json, class: "form-control" %>
28
+ </div>
29
+
30
+ <div class="form-group mb-4">
31
+ <%= f.label :description, "Description", class: "form-label" %>
32
+ <%= f.text_area :description, rows: 4, class: "form-control" %>
33
+ </div>
34
+
35
+ <%= f.submit "Save & Lock Sync", class: "btn btn-primary" %>
36
+ <% end %>
37
+ </div>
38
+ </div>