rails-ai-context 0.11.0 → 0.12.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 (30) hide show
  1. checksums.yaml +4 -4
  2. data/CLAUDE.md +4 -4
  3. data/CONTRIBUTING.md +2 -2
  4. data/README.md +9 -8
  5. data/demo_script.sh +1 -1
  6. data/docs/GUIDE.md +6 -6
  7. data/lib/rails_ai_context/configuration.rb +2 -2
  8. data/lib/rails_ai_context/introspector.rb +1 -0
  9. data/lib/rails_ai_context/introspectors/design_token_introspector.rb +187 -0
  10. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +245 -40
  11. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +75 -35
  12. data/lib/rails_ai_context/serializers/claude_serializer.rb +24 -8
  13. data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +6 -11
  14. data/lib/rails_ai_context/serializers/copilot_serializer.rb +3 -5
  15. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +7 -11
  16. data/lib/rails_ai_context/serializers/opencode_serializer.rb +12 -9
  17. data/lib/rails_ai_context/serializers/rules_serializer.rb +3 -5
  18. data/lib/rails_ai_context/serializers/windsurf_rules_serializer.rb +6 -8
  19. data/lib/rails_ai_context/serializers/windsurf_serializer.rb +3 -3
  20. data/lib/rails_ai_context/server.rb +2 -1
  21. data/lib/rails_ai_context/tools/get_controllers.rb +22 -10
  22. data/lib/rails_ai_context/tools/get_edit_context.rb +123 -0
  23. data/lib/rails_ai_context/tools/get_model_details.rb +51 -0
  24. data/lib/rails_ai_context/tools/get_stimulus.rb +3 -3
  25. data/lib/rails_ai_context/tools/get_test_info.rb +11 -0
  26. data/lib/rails_ai_context/tools/get_view.rb +8 -4
  27. data/lib/rails_ai_context/tools/search_code.rb +1 -0
  28. data/lib/rails_ai_context/version.rb +1 -1
  29. data/server.json +3 -3
  30. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bdd226d739429a34c2ea8cedc8faf12e854d0220de60f5ffb08fe8fed5459f71
4
- data.tar.gz: 7b609ca0007e97206b168cbda0937fb9fb581888491464619ba727a8a1b2d945
3
+ metadata.gz: c2116ee4ebdfbdfeeef7e91c6a4ebd9d618a84ed182cdc1d012b90c362b2a6cf
4
+ data.tar.gz: e1a084f0f348a1c549c123aa305e75c51eb45632c1c46dec396c1e6f36b3dc4e
5
5
  SHA512:
6
- metadata.gz: b54b6354d0236f8f46f813593a5472fcca35dd4ceb1aefc4631479829c541b53d8a34509602b2f5903ff1af4b34b3d43d758e41f2c8e84cb5498b3212011541a
7
- data.tar.gz: 0ddd8d1b1d413c3313a3189168c790dbbe776de2d573be006163867732287e939c0c8ea4490b39d1b86239f6940cfd923fcfab27ce24f6d9ba9bc3cda0c750fb
6
+ metadata.gz: 2d919dc0670b50c31496866b354f215413828af302cd4d63089577d64a3253f1e5150a326ef29de16d32a29a30b3ada440a16ca1500284109e92aceb4904014b
7
+ data.tar.gz: f12682e40ce7733d1dd068fd47055ba74b40e474735dea7633ae38ae6b403d8027cfae89eb5ab3eab4e3d44f9acc10253148b467267305a22bd2e482f39b177d
data/CLAUDE.md CHANGED
@@ -8,8 +8,8 @@ structure to AI assistants via the Model Context Protocol (MCP).
8
8
  - `lib/rails_ai_context.rb` — Main entry point, public API (Zeitwerk autoloaded)
9
9
  - `lib/rails_ai_context/configuration.rb` — User-facing config with presets (:standard, :full)
10
10
  - `lib/rails_ai_context/introspector.rb` — Orchestrates sub-introspectors
11
- - `lib/rails_ai_context/introspectors/` — 28 introspectors (schema, models, routes, jobs, gems, conventions, stimulus, database_stats, controllers, views, view_templates, turbo, i18n, config, active_storage, action_text, auth, api, tests, rake_tasks, assets, devops, action_mailbox, migrations, seeds, middleware, engines, multi_database)
12
- - `lib/rails_ai_context/tools/` — 11 MCP tools using the official mcp SDK
11
+ - `lib/rails_ai_context/introspectors/` — 29 introspectors (schema, models, routes, jobs, gems, conventions, stimulus, database_stats, controllers, views, view_templates, design_tokens, turbo, i18n, config, active_storage, action_text, auth, api, tests, rake_tasks, assets, devops, action_mailbox, migrations, seeds, middleware, engines, multi_database)
12
+ - `lib/rails_ai_context/tools/` — 12 MCP tools using the official mcp SDK
13
13
  - `lib/rails_ai_context/serializers/` — Output formatters (claude, claude_rules, opencode, cursor_rules, windsurf, windsurf_rules, copilot, copilot_instructions, rules, markdown, JSON)
14
14
  - `lib/rails_ai_context/resources.rb` — MCP resources (static data AI clients read directly)
15
15
  - `lib/rails_ai_context/server.rb` — MCP server configuration (stdio + HTTP transports)
@@ -32,7 +32,7 @@ structure to AI assistants via the Model Context Protocol (MCP).
32
32
  6. **Diff-aware** — context regeneration skips unchanged files
33
33
  7. **Per-assistant serializers** — each AI tool gets tailored output format
34
34
  8. **Zeitwerk autoloading** — files loaded on-demand, not all upfront
35
- 9. **Introspector presets** — `:standard` (11 core) default, `:full` (28) for power users
35
+ 9. **Introspector presets** — `:standard` (12 core) default, `:full` (28) for power users
36
36
  10. **MCP auto-discovery** — `.mcp.json` generated by install generator
37
37
  11. **Compact by default** — context files ≤150 lines, MCP tools use `detail` parameter (summary/standard/full)
38
38
  12. **Per-tool split rules** — `.claude/rules/`, `.cursor/rules/`, `.windsurf/rules/`, `.github/instructions/`
@@ -42,7 +42,7 @@ structure to AI assistants via the Model Context Protocol (MCP).
42
42
  ## Testing
43
43
 
44
44
  ```bash
45
- bundle exec rspec # Run specs (468 examples)
45
+ bundle exec rspec # Run specs (481 examples)
46
46
  bundle exec rubocop # Lint
47
47
  ```
48
48
 
data/CONTRIBUTING.md CHANGED
@@ -18,8 +18,8 @@ The test suite uses [Combustion](https://github.com/pat/combustion) to boot a mi
18
18
 
19
19
  ```
20
20
  lib/rails_ai_context/
21
- ├── introspectors/ # 28 introspectors (schema, models, routes, etc.)
22
- ├── tools/ # 11 MCP tools with detail levels and pagination
21
+ ├── introspectors/ # 29 introspectors (schema, models, routes, etc.)
22
+ ├── tools/ # 12 MCP tools with detail levels and pagination
23
23
  ├── serializers/ # Per-assistant formatters (claude, opencode, cursor, windsurf, copilot, JSON)
24
24
  ├── server.rb # MCP server setup (stdio + HTTP)
25
25
  ├── live_reload.rb # MCP live reload (file watcher + cache invalidation)
data/README.md CHANGED
@@ -27,7 +27,7 @@ Same task — *"Add status and date range filters to the Cooks index page"* —
27
27
 
28
28
  | Setup | Tokens | Saved | What it knows |
29
29
  |-------|--------|-------|---------------|
30
- | **rails-ai-context (full)** | **28,834** | **37%** | 11 MCP tools + generated docs + rules |
30
+ | **rails-ai-context (full)** | **28,834** | **37%** | 12 MCP tools + generated docs + rules |
31
31
  | rails-ai-context CLAUDE.md only | 33,106 | 27% | Generated docs + rules, no MCP tools |
32
32
  | Normal Claude `/init` | 40,700 | 11% | Generic CLAUDE.md only |
33
33
  | No rails-ai-context at all | 45,477 | baseline | Nothing — discovers everything from scratch |
@@ -95,9 +95,9 @@ The install generator creates `.mcp.json` for auto-discovery — Claude Code and
95
95
 
96
96
  ---
97
97
 
98
- ## 11 Live MCP Tools
98
+ ## 12 Live MCP Tools
99
99
 
100
- The gem exposes **11 read-only tools** via MCP that AI clients call on-demand:
100
+ The gem exposes **12 read-only tools** via MCP that AI clients call on-demand:
101
101
 
102
102
  | Tool | What it returns |
103
103
  |------|----------------|
@@ -112,6 +112,7 @@ The gem exposes **11 read-only tools** via MCP that AI clients call on-demand:
112
112
  | `rails_search_code` | Ripgrep-powered regex search across the codebase |
113
113
  | `rails_get_view` | View templates, partials, Stimulus references |
114
114
  | `rails_get_stimulus` | Stimulus controllers — targets, values, actions, outlets |
115
+ | `rails_get_edit_context` | Surgical edit helper — returns code around a match with line numbers |
115
116
 
116
117
  ### Smart Detail Levels
117
118
 
@@ -201,7 +202,7 @@ Root files (CLAUDE.md, AGENTS.md, etc.) use **section markers** — your custom
201
202
  | **DevOps** | Puma, Procfile, Docker, deployment tools, asset pipeline |
202
203
  | **Architecture** | Service objects, STI, polymorphism, state machines, multi-tenancy, engines |
203
204
 
204
- 28 introspectors total. The `:standard` preset runs 11 core ones by default; use `:full` for 27 (`database_stats` is opt-in, PostgreSQL only).
205
+ 29 introspectors total. The `:standard` preset runs 12 core ones by default; use `:full` for 28 (`database_stats` is opt-in, PostgreSQL only).
205
206
 
206
207
  ---
207
208
 
@@ -255,7 +256,7 @@ end
255
256
  ```ruby
256
257
  # config/initializers/rails_ai_context.rb
257
258
  RailsAiContext.configure do |config|
258
- # Presets: :standard (11 introspectors, default) or :full (all 28)
259
+ # Presets: :standard (12 introspectors, default) or :full (all 28)
259
260
  config.preset = :standard
260
261
 
261
262
  # Cherry-pick on top of a preset
@@ -289,7 +290,7 @@ end
289
290
  | Option | Default | Description |
290
291
  |--------|---------|-------------|
291
292
  | `preset` | `:standard` | Introspector preset (`:standard` or `:full`) |
292
- | `introspectors` | 11 core | Array of introspector symbols |
293
+ | `introspectors` | 12 core | Array of introspector symbols |
293
294
  | `context_mode` | `:compact` | `:compact` (≤150 lines) or `:full` (dump everything) |
294
295
  | `claude_max_lines` | `150` | Max lines for CLAUDE.md in compact mode |
295
296
  | `max_tool_response_chars` | `120_000` | Safety cap for MCP tool responses |
@@ -350,7 +351,7 @@ Works with every Rails architecture — auto-detects what's relevant:
350
351
 
351
352
  | Setup | Coverage | Notes |
352
353
  |-------|----------|-------|
353
- | Rails full-stack (ERB + Hotwire) | 28/28 | All introspectors relevant |
354
+ | Rails full-stack (ERB + Hotwire) | 29/29 | All introspectors relevant |
354
355
  | Rails + Inertia.js (React/Vue) | ~22/27 | Views/Turbo partially useful, backend fully covered |
355
356
  | Rails API + React/Next.js SPA | ~20/27 | Schema, models, routes, API, auth, jobs — all covered |
356
357
  | Rails API + mobile app | ~20/27 | Same as SPA — backend introspection is identical |
@@ -389,7 +390,7 @@ The gem parses `db/schema.rb` as text when no database is connected. Works in CI
389
390
  ```bash
390
391
  git clone https://github.com/crisnahine/rails-ai-context.git
391
392
  cd rails-ai-context && bundle install
392
- bundle exec rspec # 468 examples
393
+ bundle exec rspec # 481 examples
393
394
  bundle exec rubocop # Lint
394
395
  ```
395
396
 
data/demo_script.sh CHANGED
@@ -8,7 +8,7 @@ echo 'Fetching gem metadata from https://rubygems.org...'
8
8
  sleep 0.3
9
9
  echo 'Resolving dependencies...'
10
10
  sleep 0.3
11
- echo 'Installing rails-ai-context 0.11.0'
11
+ echo 'Installing rails-ai-context 0.12.0'
12
12
  echo ''
13
13
  sleep 1
14
14
 
data/docs/GUIDE.md CHANGED
@@ -246,7 +246,7 @@ rails ai:context:claude # Use this instead (no quoting needed)
246
246
 
247
247
  ## MCP Tools — Full Reference
248
248
 
249
- All 11 tools are **read-only** and **idempotent** — they never modify your application or database.
249
+ All 12 tools are **read-only** and **idempotent** — they never modify your application or database.
250
250
 
251
251
  ### rails_get_schema
252
252
 
@@ -568,7 +568,7 @@ RailsAiContext.configure do |config|
568
568
  end
569
569
  ```
570
570
 
571
- Both transports are **read-only** — they expose the same 11 tools and never modify your app.
571
+ Both transports are **read-only** — they expose the same 12 tools and never modify your app.
572
572
 
573
573
  ---
574
574
 
@@ -579,7 +579,7 @@ Both transports are **read-only** — they expose the same 11 tools and never mo
579
579
  RailsAiContext.configure do |config|
580
580
  # --- Introspectors ---
581
581
 
582
- # Presets: :standard (11 core, default) or :full (all 28)
582
+ # Presets: :standard (12 core, default) or :full (all 28)
583
583
  config.preset = :standard
584
584
 
585
585
  # Cherry-pick on top of a preset
@@ -636,7 +636,7 @@ end
636
636
  | Option | Type | Default | Description |
637
637
  |--------|------|---------|-------------|
638
638
  | `preset` | Symbol | `:standard` | Introspector preset (`:standard` or `:full`) |
639
- | `introspectors` | Array | 11 core symbols | Which introspectors to run |
639
+ | `introspectors` | Array | 12 core symbols | Which introspectors to run |
640
640
  | `context_mode` | Symbol | `:compact` | `:compact` or `:full` |
641
641
  | `claude_max_lines` | Integer | `150` | Max lines for CLAUDE.md in compact mode |
642
642
  | `max_tool_response_chars` | Integer | `120_000` | Safety cap for MCP tool responses |
@@ -689,7 +689,7 @@ These run by default. Fast and cover core Rails structure.
689
689
  | `tests` | Test framework (rspec/minitest), factories/fixtures with locations and counts, system tests, CI config files, coverage tool, test helpers, VCR cassettes. |
690
690
  | `migrations` | Total count, schema version, pending migrations, recent migration history with detected actions (create_table, add_column, etc.), migration statistics. |
691
691
 
692
- ### Full preset (28 introspectors)
692
+ ### Full preset (29 introspectors)
693
693
 
694
694
  Includes all standard introspectors plus:
695
695
 
@@ -812,7 +812,7 @@ OpenCode uses **per-directory lazy-loading**: when the agent reads a file, it wa
812
812
 
813
813
  | Setup | Coverage | Notes |
814
814
  |-------|----------|-------|
815
- | Rails full-stack (ERB + Hotwire) | 28/28 | All introspectors relevant |
815
+ | Rails full-stack (ERB + Hotwire) | 29/29 | All introspectors relevant |
816
816
  | Rails + Inertia.js (React/Vue) | ~22/27 | Views/Turbo partially useful, backend fully covered |
817
817
  | Rails API + React/Next.js SPA | ~20/27 | Schema, models, routes, API, auth, jobs — all covered |
818
818
  | Rails API + mobile app | ~20/27 | Same as SPA — backend introspection is identical |
@@ -3,8 +3,8 @@
3
3
  module RailsAiContext
4
4
  class Configuration
5
5
  PRESETS = {
6
- standard: %i[schema models routes jobs gems conventions controllers tests migrations stimulus view_templates],
7
- full: %i[schema models routes jobs gems conventions stimulus controllers views view_templates turbo
6
+ standard: %i[schema models routes jobs gems conventions controllers tests migrations stimulus view_templates design_tokens],
7
+ full: %i[schema models routes jobs gems conventions stimulus controllers views view_templates design_tokens turbo
8
8
  i18n config active_storage action_text auth api tests rake_tasks assets
9
9
  devops action_mailbox migrations seeds middleware engines multi_database]
10
10
  }.freeze
@@ -58,6 +58,7 @@ module RailsAiContext
58
58
  when :controllers then Introspectors::ControllerIntrospector.new(app)
59
59
  when :views then Introspectors::ViewIntrospector.new(app)
60
60
  when :view_templates then Introspectors::ViewTemplateIntrospector.new(app)
61
+ when :design_tokens then Introspectors::DesignTokenIntrospector.new(app)
61
62
  when :turbo then Introspectors::TurboIntrospector.new(app)
62
63
  when :i18n then Introspectors::I18nIntrospector.new(app)
63
64
  when :config then Introspectors::ConfigIntrospector.new(app)
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Introspectors
5
+ # Extracts design tokens from CSS/SCSS files across ALL Rails CSS setups:
6
+ # - Tailwind v4 @theme blocks
7
+ # - Tailwind v4 built CSS :root variables
8
+ # - Tailwind v3 tailwind.config.js (simple key-values only)
9
+ # - Bootstrap/Sass $variable definitions
10
+ # - Plain CSS :root custom properties
11
+ # - Webpacker-era stylesheets
12
+ # - ViewComponent sidecar CSS
13
+ #
14
+ # Returns a framework-agnostic hash of design tokens.
15
+ # No external dependencies — pure regex parsing.
16
+ class DesignTokenIntrospector
17
+ attr_reader :app
18
+
19
+ def initialize(app)
20
+ @app = app
21
+ end
22
+
23
+ def call
24
+ root = app.root.to_s
25
+ tokens = {}
26
+
27
+ # Priority order: check each source, merge found tokens
28
+ extract_built_css_vars(root, tokens)
29
+ extract_tailwind_v4_theme(root, tokens)
30
+ extract_tailwind_v3_config(root, tokens)
31
+ extract_scss_variables(root, tokens)
32
+ extract_css_custom_properties(root, tokens)
33
+ extract_webpacker_styles(root, tokens)
34
+ extract_component_css(root, tokens)
35
+
36
+ return { skipped: true, reason: "No design tokens found" } if tokens.empty?
37
+
38
+ {
39
+ framework: detect_framework(root),
40
+ tokens: tokens
41
+ }
42
+ rescue => e
43
+ { error: e.message }
44
+ end
45
+
46
+ private
47
+
48
+ def detect_framework(root)
49
+ gemfile = File.join(root, "Gemfile")
50
+ return "unknown" unless File.exist?(gemfile)
51
+ content = File.read(gemfile, encoding: "UTF-8", invalid: :replace, undef: :replace)
52
+
53
+ if content.include?("tailwindcss-rails")
54
+ "tailwind"
55
+ elsif content.include?("bootstrap")
56
+ "bootstrap"
57
+ elsif content.include?("dartsass-rails") || content.include?("sassc-rails") || content.include?("sass-rails")
58
+ "sass"
59
+ elsif content.include?("cssbundling-rails")
60
+ "cssbundling"
61
+ else
62
+ "plain_css"
63
+ end
64
+ rescue
65
+ "unknown"
66
+ end
67
+
68
+ # 1. Built CSS output (Tailwind v4, cssbundling-rails, dartsass-rails)
69
+ def extract_built_css_vars(root, tokens)
70
+ Dir.glob(File.join(root, "app", "assets", "builds", "*.css")).each do |path|
71
+ content = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace) rescue next
72
+ extract_root_vars(content, tokens)
73
+ end
74
+ end
75
+
76
+ # 2. Tailwind v4 @theme blocks in source CSS
77
+ def extract_tailwind_v4_theme(root, tokens)
78
+ %w[app/assets/tailwind app/assets/stylesheets].each do |dir|
79
+ Dir.glob(File.join(root, dir, "**", "*.css")).each do |path|
80
+ content = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace) rescue next
81
+ content.scan(/@theme\s*(?:inline)?\s*\{([^}]+)\}/m).each do |match|
82
+ match[0].scan(/--([a-zA-Z0-9-]+):\s*([^;]+);/).each do |name, value|
83
+ tokens["--#{name}"] = value.strip
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+
90
+ # 3. Tailwind v3 config (regex on JS — handles nested color palettes)
91
+ def extract_tailwind_v3_config(root, tokens) # rubocop:disable Metrics/MethodLength
92
+ path = File.join(root, "config", "tailwind.config.js")
93
+ path = File.join(root, "tailwind.config.js") unless File.exist?(path)
94
+ return unless File.exist?(path)
95
+
96
+ content = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace) rescue return
97
+
98
+ # Extract ALL hex/rgb/hsl color values with their context
99
+ # Pattern: 'key': '#hex' or "key": "rgb(...)" anywhere in file
100
+ content.scan(/['"]([\w-]+)['"]\s*:\s*['"]([#][\da-fA-F]{3,8})['"]/).each do |name, value|
101
+ tokens["tw3-#{name}"] = value
102
+ end
103
+
104
+ # Extract color shades: number keys with hex values (inside palette objects)
105
+ content.scan(/['"]?(\d{2,3})['"]?\s*:\s*['"]([#][\da-fA-F]{3,8})['"]/).each do |shade, value|
106
+ tokens["tw3-shade-#{shade}"] = value
107
+ end
108
+
109
+ # Extract named color strings: surface: '#ffffff'
110
+ content.scan(/(\w+)\s*:\s*['"]([#][\da-fA-F]{3,8})['"]/).each do |name, value|
111
+ next if name.match?(/\A\d/)
112
+ tokens["tw3-#{name}"] = value
113
+ end
114
+
115
+ # Extract fontFamily arrays
116
+ content.scan(/(\w+)\s*:\s*\[['"]([^'"]+)['"]/).each do |name, font|
117
+ tokens["tw3-font-#{name}"] = font if name.match?(/font|sans|serif|mono|display|heading/)
118
+ end
119
+ end
120
+
121
+ # 4. Bootstrap/Sass variable definitions
122
+ def extract_scss_variables(root, tokens)
123
+ %w[app/assets/stylesheets app/assets/stylesheets/config].each do |dir|
124
+ Dir.glob(File.join(root, dir, "**", "*.{scss,sass}")).each do |path|
125
+ content = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace) rescue next
126
+ content.scan(/^\$([a-zA-Z][\w-]*)\s*:\s*([^;!]+)/).each do |name, value|
127
+ value = value.strip
128
+ # Skip computed values (references to other variables, functions)
129
+ next if value.match?(/\$\w|lighten|darken|mix|adjust|scale|rgba\(\$/)
130
+ tokens["$#{name}"] = value
131
+ end
132
+ end
133
+ end
134
+ end
135
+
136
+ # 5. CSS custom properties in stylesheet files
137
+ def extract_css_custom_properties(root, tokens)
138
+ %w[app/assets/stylesheets].each do |dir|
139
+ Dir.glob(File.join(root, dir, "**", "*.css")).each do |path|
140
+ content = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace) rescue next
141
+ extract_root_vars(content, tokens)
142
+ end
143
+ end
144
+ end
145
+
146
+ # 6. Webpacker-era stylesheets (Rails 6)
147
+ def extract_webpacker_styles(root, tokens)
148
+ %w[app/javascript/stylesheets app/javascript/css].each do |dir|
149
+ full_dir = File.join(root, dir)
150
+ next unless Dir.exist?(full_dir)
151
+
152
+ Dir.glob(File.join(full_dir, "**", "*.{scss,sass,css}")).each do |path|
153
+ content = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace) rescue next
154
+ # Sass variables
155
+ content.scan(/^\$([a-zA-Z][\w-]*)\s*:\s*([^;!]+)/).each do |name, value|
156
+ value = value.strip
157
+ next if value.match?(/\$\w|lighten|darken|mix/)
158
+ tokens["$#{name}"] = value
159
+ end
160
+ # CSS custom properties
161
+ extract_root_vars(content, tokens)
162
+ end
163
+ end
164
+ end
165
+
166
+ # 7. ViewComponent sidecar CSS
167
+ def extract_component_css(root, tokens)
168
+ dir = File.join(root, "app", "components")
169
+ return unless Dir.exist?(dir)
170
+
171
+ Dir.glob(File.join(dir, "**", "*.css")).each do |path|
172
+ content = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace) rescue next
173
+ extract_root_vars(content, tokens)
174
+ end
175
+ end
176
+
177
+ # Helper: extract :root { --var: value } from CSS content
178
+ def extract_root_vars(content, tokens)
179
+ content.scan(/:root\s*(?:,\s*:host)?\s*\{([^}]+)\}/m).each do |match|
180
+ match[0].scan(/--([a-zA-Z0-9-]+):\s*([^;]+);/).each do |name, value|
181
+ tokens["--#{name}"] = value.strip
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end