senren-ui 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 (182) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +33 -0
  3. data/CONTRIBUTING.md +63 -0
  4. data/LICENSE +21 -0
  5. data/README.md +135 -0
  6. data/Rakefile +22 -0
  7. data/docs/visual_style.md +51 -0
  8. data/lib/generators/senren/component/component_generator.rb +62 -0
  9. data/lib/generators/senren/component/templates/component.html.erb.tt +3 -0
  10. data/lib/generators/senren/component/templates/component.rb.tt +13 -0
  11. data/lib/generators/senren/component/templates/component_test.rb.tt +16 -0
  12. data/lib/generators/senren/component/templates/controller.js.tt +23 -0
  13. data/lib/generators/senren/component/templates/system_test.rb.tt +7 -0
  14. data/lib/generators/senren/install/install_generator.rb +67 -0
  15. data/lib/generators/senren/install/templates/base_component.rb.tt +45 -0
  16. data/lib/generators/senren/install/templates/conventions.md.tt +66 -0
  17. data/lib/generators/senren/install/templates/installed_components.yml.tt +4 -0
  18. data/lib/generators/senren/install/templates/senren.css.tt +164 -0
  19. data/lib/senren/rails/component_copier.rb +111 -0
  20. data/lib/senren/rails/doctor.rb +86 -0
  21. data/lib/senren/rails/engine.rb +16 -0
  22. data/lib/senren/rails/host_paths.rb +36 -0
  23. data/lib/senren/rails/installer.rb +83 -0
  24. data/lib/senren/rails/llms_writer.rb +149 -0
  25. data/lib/senren/rails/registry.rb +161 -0
  26. data/lib/senren/rails/skill_writer.rb +166 -0
  27. data/lib/senren/rails/version.rb +7 -0
  28. data/lib/senren/rails.rb +39 -0
  29. data/lib/tasks/senren.rake +74 -0
  30. data/registry/components.yml +1053 -0
  31. data/registry/groups.yml +25 -0
  32. data/registry/recipes.yml +79 -0
  33. data/templates/components/accordion/accordion_component.html.erb +16 -0
  34. data/templates/components/accordion/accordion_component.rb +31 -0
  35. data/templates/components/activity_feed/activity_feed_component.html.erb +22 -0
  36. data/templates/components/activity_feed/activity_feed_component.rb +19 -0
  37. data/templates/components/alert/alert_component.html.erb +9 -0
  38. data/templates/components/alert/alert_component.rb +18 -0
  39. data/templates/components/alert_dialog/alert_dialog_component.html.erb +34 -0
  40. data/templates/components/alert_dialog/alert_dialog_component.rb +21 -0
  41. data/templates/components/api_key_field/api_key_field_component.html.erb +13 -0
  42. data/templates/components/api_key_field/api_key_field_component.rb +20 -0
  43. data/templates/components/app_shell/app_shell_component.html.erb +28 -0
  44. data/templates/components/app_shell/app_shell_component.rb +24 -0
  45. data/templates/components/aspect_ratio/aspect_ratio_component.html.erb +3 -0
  46. data/templates/components/aspect_ratio/aspect_ratio_component.rb +14 -0
  47. data/templates/components/avatar/avatar_component.html.erb +27 -0
  48. data/templates/components/avatar/avatar_component.rb +30 -0
  49. data/templates/components/badge/badge_component.html.erb +1 -0
  50. data/templates/components/badge/badge_component.rb +16 -0
  51. data/templates/components/billing_plan_card/billing_plan_card_component.html.erb +28 -0
  52. data/templates/components/billing_plan_card/billing_plan_card_component.rb +27 -0
  53. data/templates/components/breadcrumb/breadcrumb_component.html.erb +23 -0
  54. data/templates/components/breadcrumb/breadcrumb_component.rb +30 -0
  55. data/templates/components/bulk_action_bar/bulk_action_bar_component.html.erb +12 -0
  56. data/templates/components/bulk_action_bar/bulk_action_bar_component.rb +24 -0
  57. data/templates/components/button/button_component.html.erb +6 -0
  58. data/templates/components/button/button_component.rb +29 -0
  59. data/templates/components/calendar/calendar_component.html.erb +21 -0
  60. data/templates/components/calendar/calendar_component.rb +30 -0
  61. data/templates/components/card/card_component.html.erb +13 -0
  62. data/templates/components/card/card_component.rb +17 -0
  63. data/templates/components/carousel/carousel_component.html.erb +68 -0
  64. data/templates/components/carousel/carousel_component.rb +34 -0
  65. data/templates/components/checkbox/checkbox_component.html.erb +8 -0
  66. data/templates/components/checkbox/checkbox_component.rb +19 -0
  67. data/templates/components/checkbox_group/checkbox_group_component.html.erb +10 -0
  68. data/templates/components/checkbox_group/checkbox_group_component.rb +30 -0
  69. data/templates/components/clipboard/clipboard_component.html.erb +7 -0
  70. data/templates/components/clipboard/clipboard_component.rb +17 -0
  71. data/templates/components/codeblock/codeblock_component.html.erb +11 -0
  72. data/templates/components/codeblock/codeblock_component.rb +31 -0
  73. data/templates/components/collapsible/collapsible_component.html.erb +9 -0
  74. data/templates/components/collapsible/collapsible_component.rb +19 -0
  75. data/templates/components/combobox/combobox_component.html.erb +19 -0
  76. data/templates/components/combobox/combobox_component.rb +38 -0
  77. data/templates/components/command/command_component.html.erb +22 -0
  78. data/templates/components/command/command_component.rb +38 -0
  79. data/templates/components/context_menu/context_menu_component.html.erb +11 -0
  80. data/templates/components/context_menu/context_menu_component.rb +11 -0
  81. data/templates/components/data_table/data_table_component.html.erb +50 -0
  82. data/templates/components/data_table/data_table_component.rb +42 -0
  83. data/templates/components/date_picker/date_picker_component.html.erb +5 -0
  84. data/templates/components/date_picker/date_picker_component.rb +21 -0
  85. data/templates/components/dialog/dialog_component.html.erb +38 -0
  86. data/templates/components/dialog/dialog_component.rb +22 -0
  87. data/templates/components/dropdown_menu/dropdown_menu_component.html.erb +12 -0
  88. data/templates/components/dropdown_menu/dropdown_menu_component.rb +36 -0
  89. data/templates/components/empty_state/empty_state_component.html.erb +18 -0
  90. data/templates/components/empty_state/empty_state_component.rb +22 -0
  91. data/templates/components/filter_bar/filter_bar_component.html.erb +5 -0
  92. data/templates/components/filter_bar/filter_bar_component.rb +15 -0
  93. data/templates/components/form/form_component.html.erb +3 -0
  94. data/templates/components/form/form_component.rb +18 -0
  95. data/templates/components/hover_card/hover_card_component.html.erb +10 -0
  96. data/templates/components/hover_card/hover_card_component.rb +11 -0
  97. data/templates/components/input/input_component.html.erb +1 -0
  98. data/templates/components/input/input_component.rb +28 -0
  99. data/templates/components/invite_member_dialog/invite_member_dialog_component.html.erb +35 -0
  100. data/templates/components/invite_member_dialog/invite_member_dialog_component.rb +26 -0
  101. data/templates/components/label/label_component.html.erb +4 -0
  102. data/templates/components/label/label_component.rb +19 -0
  103. data/templates/components/link/link_component.html.erb +1 -0
  104. data/templates/components/link/link_component.rb +25 -0
  105. data/templates/components/masked_input/masked_input_component.html.erb +1 -0
  106. data/templates/components/masked_input/masked_input_component.rb +18 -0
  107. data/templates/components/native_select/native_select_component.html.erb +14 -0
  108. data/templates/components/native_select/native_select_component.rb +52 -0
  109. data/templates/components/page_header/page_header_component.html.erb +20 -0
  110. data/templates/components/page_header/page_header_component.rb +19 -0
  111. data/templates/components/pagination/pagination_component.html.erb +11 -0
  112. data/templates/components/pagination/pagination_component.rb +24 -0
  113. data/templates/components/popover/popover_component.html.erb +9 -0
  114. data/templates/components/popover/popover_component.rb +11 -0
  115. data/templates/components/progress/progress_component.html.erb +11 -0
  116. data/templates/components/progress/progress_component.rb +26 -0
  117. data/templates/components/radio_button/radio_button_component.html.erb +8 -0
  118. data/templates/components/radio_button/radio_button_component.rb +19 -0
  119. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.html.erb +32 -0
  120. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.rb +30 -0
  121. data/templates/components/search_input/search_input_component.html.erb +14 -0
  122. data/templates/components/search_input/search_input_component.rb +18 -0
  123. data/templates/components/select/select_component.html.erb +1 -0
  124. data/templates/components/select/select_component.rb +19 -0
  125. data/templates/components/separator/separator_component.html.erb +1 -0
  126. data/templates/components/separator/separator_component.rb +12 -0
  127. data/templates/components/settings_section/settings_section_component.html.erb +20 -0
  128. data/templates/components/settings_section/settings_section_component.rb +18 -0
  129. data/templates/components/sheet/sheet_component.html.erb +37 -0
  130. data/templates/components/sheet/sheet_component.rb +27 -0
  131. data/templates/components/shortcut_key/shortcut_key_component.html.erb +6 -0
  132. data/templates/components/shortcut_key/shortcut_key_component.rb +15 -0
  133. data/templates/components/sidebar/sidebar_component.html.erb +14 -0
  134. data/templates/components/sidebar/sidebar_component.rb +37 -0
  135. data/templates/components/skeleton/skeleton_component.html.erb +1 -0
  136. data/templates/components/skeleton/skeleton_component.rb +13 -0
  137. data/templates/components/stat_card/stat_card_component.html.erb +20 -0
  138. data/templates/components/stat_card/stat_card_component.rb +31 -0
  139. data/templates/components/switch/switch_component.html.erb +11 -0
  140. data/templates/components/switch/switch_component.rb +19 -0
  141. data/templates/components/table/table_component.html.erb +26 -0
  142. data/templates/components/table/table_component.rb +35 -0
  143. data/templates/components/tabs/tabs_component.html.erb +18 -0
  144. data/templates/components/tabs/tabs_component.rb +35 -0
  145. data/templates/components/team_member_list/team_member_list_component.html.erb +22 -0
  146. data/templates/components/team_member_list/team_member_list_component.rb +26 -0
  147. data/templates/components/textarea/textarea_component.html.erb +1 -0
  148. data/templates/components/textarea/textarea_component.rb +23 -0
  149. data/templates/components/theme_toggle/theme_toggle_component.html.erb +4 -0
  150. data/templates/components/theme_toggle/theme_toggle_component.rb +15 -0
  151. data/templates/components/tooltip/tooltip_component.html.erb +9 -0
  152. data/templates/components/tooltip/tooltip_component.rb +16 -0
  153. data/templates/components/top_nav/top_nav_component.html.erb +21 -0
  154. data/templates/components/top_nav/top_nav_component.rb +44 -0
  155. data/templates/components/typography/typography_component.html.erb +1 -0
  156. data/templates/components/typography/typography_component.rb +24 -0
  157. data/templates/controllers/accordion_controller.js +27 -0
  158. data/templates/controllers/alert_dialog_controller.js +38 -0
  159. data/templates/controllers/api_key_field_controller.js +36 -0
  160. data/templates/controllers/calendar_controller.js +16 -0
  161. data/templates/controllers/carousel_controller.js +50 -0
  162. data/templates/controllers/clipboard_controller.js +17 -0
  163. data/templates/controllers/collapsible_controller.js +13 -0
  164. data/templates/controllers/combobox_controller.js +64 -0
  165. data/templates/controllers/command_controller.js +80 -0
  166. data/templates/controllers/context_menu_controller.js +36 -0
  167. data/templates/controllers/data_table_controller.js +34 -0
  168. data/templates/controllers/date_picker_controller.js +17 -0
  169. data/templates/controllers/dialog_controller.js +50 -0
  170. data/templates/controllers/dropdown_menu_controller.js +92 -0
  171. data/templates/controllers/hover_card_controller.js +17 -0
  172. data/templates/controllers/invite_member_dialog_controller.js +28 -0
  173. data/templates/controllers/masked_input_controller.js +30 -0
  174. data/templates/controllers/popover_controller.js +42 -0
  175. data/templates/controllers/rich_text_editor_lite_controller.js +443 -0
  176. data/templates/controllers/select_controller.js +10 -0
  177. data/templates/controllers/sheet_controller.js +34 -0
  178. data/templates/controllers/sidebar_controller.js +10 -0
  179. data/templates/controllers/tabs_controller.js +41 -0
  180. data/templates/controllers/theme_toggle_controller.js +24 -0
  181. data/templates/controllers/tooltip_controller.js +10 -0
  182. metadata +257 -0
@@ -0,0 +1,164 @@
1
+ /*
2
+ * Senren UI - Spring Garden tokens.
3
+ * HSL channels are written without hsl() wrapping so Tailwind utilities like
4
+ * bg-[hsl(var(--senren-background))]
5
+ * can compose them with opacity modifiers.
6
+ *
7
+ * The palette is intentionally colorful: pond blue, sakura pink, young leaf
8
+ * green, iris violet, and warm paper. Components stay SaaS-practical while
9
+ * avoiding the black-and-white default UI look.
10
+ */
11
+
12
+ :root {
13
+ --senren-background: 54 100% 97%;
14
+ --senren-foreground: 178 62% 12%;
15
+
16
+ --senren-muted: 83 54% 91%;
17
+ --senren-muted-foreground: 181 24% 34%;
18
+
19
+ --senren-card: 52 100% 99%;
20
+ --senren-card-foreground: 178 62% 12%;
21
+
22
+ --senren-popover: 52 100% 99%;
23
+ --senren-popover-foreground: 178 62% 12%;
24
+
25
+ --senren-border: 174 42% 78%;
26
+ --senren-input: 174 42% 78%;
27
+ --senren-ring: 196 86% 48%;
28
+
29
+ --senren-primary: 151 74% 29%;
30
+ --senren-primary-foreground: 58 100% 97%;
31
+
32
+ --senren-secondary: 334 95% 89%;
33
+ --senren-secondary-foreground: 178 56% 18%;
34
+
35
+ --senren-accent: 194 86% 82%;
36
+ --senren-accent-foreground: 188 72% 18%;
37
+
38
+ --senren-destructive: 350 78% 55%;
39
+ --senren-destructive-foreground: 54 100% 97%;
40
+
41
+ --senren-success: 132 52% 37%;
42
+ --senren-success-foreground: 54 100% 97%;
43
+
44
+ --senren-warning: 44 92% 61%;
45
+ --senren-warning-foreground: 38 58% 16%;
46
+
47
+ --senren-radius: 0.5rem;
48
+
49
+ --senren-palette-sky: 202 88% 44%;
50
+ --senren-palette-sakura: 334 95% 89%;
51
+ --senren-palette-pond: 194 86% 82%;
52
+ --senren-palette-leaf: 88 58% 57%;
53
+ --senren-palette-iris: 255 70% 80%;
54
+ --senren-palette-paper: 54 100% 97%;
55
+ }
56
+
57
+ .dark {
58
+ --senren-background: 202 58% 9%;
59
+ --senren-foreground: 54 100% 94%;
60
+
61
+ --senren-muted: 188 34% 17%;
62
+ --senren-muted-foreground: 174 28% 72%;
63
+
64
+ --senren-card: 199 50% 13%;
65
+ --senren-card-foreground: 54 100% 94%;
66
+
67
+ --senren-popover: 199 50% 13%;
68
+ --senren-popover-foreground: 54 100% 94%;
69
+
70
+ --senren-border: 188 31% 25%;
71
+ --senren-input: 188 31% 25%;
72
+ --senren-ring: 194 86% 72%;
73
+
74
+ --senren-primary: 132 55% 66%;
75
+ --senren-primary-foreground: 166 62% 10%;
76
+
77
+ --senren-secondary: 334 74% 74%;
78
+ --senren-secondary-foreground: 202 58% 9%;
79
+
80
+ --senren-accent: 194 76% 63%;
81
+ --senren-accent-foreground: 202 58% 9%;
82
+
83
+ --senren-destructive: 350 72% 62%;
84
+ --senren-destructive-foreground: 54 100% 97%;
85
+
86
+ --senren-success: 132 55% 66%;
87
+ --senren-success-foreground: 166 62% 10%;
88
+
89
+ --senren-warning: 44 92% 68%;
90
+ --senren-warning-foreground: 38 58% 16%;
91
+ }
92
+
93
+ /* Convenience aliases. Components prefer Tailwind semantic utilities, but raw HTML can use these. */
94
+ .senren-bg { background-color: hsl(var(--senren-background)); }
95
+ .senren-fg { color: hsl(var(--senren-foreground)); }
96
+ .senren-border { border-color: hsl(var(--senren-border)); }
97
+ .senren-radius { border-radius: var(--senren-radius); }
98
+
99
+ [data-senren-component="rich_text_editor_lite"] [contenteditable] a {
100
+ color: hsl(var(--senren-primary));
101
+ cursor: pointer;
102
+ font-weight: 500;
103
+ text-decoration-line: underline;
104
+ text-decoration-thickness: 0.08em;
105
+ text-underline-offset: 0.18em;
106
+ }
107
+
108
+ [data-senren-component="rich_text_editor_lite"] [contenteditable] a:hover {
109
+ color: hsl(var(--senren-ring));
110
+ }
111
+
112
+ [data-senren-component="rich_text_editor_lite"] [contenteditable] ul,
113
+ [data-senren-component="rich_text_editor_lite"] [contenteditable] ol {
114
+ margin-block: 0.5rem;
115
+ padding-inline-start: 1.35rem;
116
+ }
117
+
118
+ [data-senren-component="rich_text_editor_lite"] [contenteditable] ul {
119
+ list-style: disc;
120
+ }
121
+
122
+ [data-senren-component="rich_text_editor_lite"] [contenteditable] ol {
123
+ list-style: decimal;
124
+ }
125
+
126
+ [data-senren-component="rich_text_editor_lite"] [contenteditable] li + li {
127
+ margin-top: 0.25rem;
128
+ }
129
+
130
+ [data-senren-component="rich_text_editor_lite"] [contenteditable] p + p {
131
+ margin-top: 0.5rem;
132
+ }
133
+
134
+ [data-senren-component="rich_text_editor_lite"] [contenteditable] h1,
135
+ [data-senren-component="rich_text_editor_lite"] [contenteditable] h2,
136
+ [data-senren-component="rich_text_editor_lite"] [contenteditable] h3 {
137
+ color: hsl(var(--senren-foreground));
138
+ font-weight: 700;
139
+ line-height: 1.2;
140
+ }
141
+
142
+ [data-senren-component="rich_text_editor_lite"] [contenteditable] h1 {
143
+ font-size: 1.5rem;
144
+ }
145
+
146
+ [data-senren-component="rich_text_editor_lite"] [contenteditable] h2 {
147
+ font-size: 1.25rem;
148
+ }
149
+
150
+ [data-senren-component="rich_text_editor_lite"] [contenteditable] h3 {
151
+ font-size: 1.125rem;
152
+ }
153
+
154
+ [data-senren-component="rich_text_editor_lite"] [contenteditable] :is(p, h1, h2, h3, li)[data-align="center"] {
155
+ text-align: center;
156
+ }
157
+
158
+ [data-senren-component="rich_text_editor_lite"] [contenteditable] :is(p, h1, h2, h3, li)[data-align="right"] {
159
+ text-align: right;
160
+ }
161
+
162
+ [data-senren-component="rich_text_editor_lite"] [contenteditable] :is(p, h1, h2, h3, li)[data-align="justify"] {
163
+ text-align: justify;
164
+ }
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'yaml'
5
+ require 'time'
6
+
7
+ module Senren
8
+ module Rails
9
+ # Copies component files from the gem's templates/ tree into the host
10
+ # Rails app, and updates .senren/installed_components.yml.
11
+ class ComponentCopier
12
+ attr_reader :registry, :paths, :stdout
13
+
14
+ def initialize(registry: Registry.load!, paths: HostPaths.new, stdout: $stdout)
15
+ @registry = registry
16
+ @paths = paths
17
+ @stdout = stdout
18
+ end
19
+
20
+ # Installs a list of components (with deps), respecting the override flag.
21
+ # Returns the ordered list of component names actually installed.
22
+ def install(component_names, client_override: nil, force: false)
23
+ wanted = registry.dependencies(*component_names)
24
+ paths.ensure_dirs!
25
+
26
+ wanted.each do |name|
27
+ comp = registry.fetch(name)
28
+ install_component(comp, client_override: client_override, force: force)
29
+ end
30
+
31
+ update_installed_ledger(wanted, client_override: client_override)
32
+ wanted
33
+ end
34
+
35
+ private
36
+
37
+ def install_component(comp, client_override:, force:)
38
+ effective_client = effective_client_for(comp, client_override)
39
+
40
+ comp.files.each do |relative|
41
+ next if relative.include?('javascript/controllers') && !effective_client
42
+
43
+ src = source_for(comp, relative)
44
+ dest = paths.root.join(relative)
45
+ copy_file(src, dest, force: force, label: "#{comp.name}::#{File.basename(relative)}")
46
+ end
47
+ end
48
+
49
+ def effective_client_for(comp, override)
50
+ return comp.client? if override.nil?
51
+ return false unless comp.can_have_client
52
+
53
+ override
54
+ end
55
+
56
+ def source_for(comp, relative)
57
+ # Map host path back to gem template path.
58
+ # app/components/senren/<name>_component.rb -> templates/components/<name>/<name>_component.rb
59
+ # app/components/senren/<name>_component.html.erb -> templates/components/<name>/<name>_component.html.erb
60
+ # app/javascript/controllers/senren/<name>_controller.js -> templates/controllers/<name>_controller.js
61
+ base = File.basename(relative)
62
+ if relative.include?('app/components/senren/')
63
+ File.join(Senren::Rails.templates_root, 'components', comp.name, base)
64
+ elsif relative.include?('app/javascript/controllers/senren/')
65
+ File.join(Senren::Rails.templates_root, 'controllers', base)
66
+ else
67
+ raise "ComponentCopier: do not know how to map #{relative.inspect}"
68
+ end
69
+ end
70
+
71
+ def copy_file(src, dest, force:, label:)
72
+ unless File.exist?(src)
73
+ stdout.puts " warn missing template: #{src} (#{label})"
74
+ return
75
+ end
76
+ if File.exist?(dest) && !force
77
+ stdout.puts " skip #{dest} (already exists)"
78
+ return
79
+ end
80
+ FileUtils.mkdir_p(File.dirname(dest))
81
+ FileUtils.cp(src, dest)
82
+ stdout.puts " copy #{dest}"
83
+ end
84
+
85
+ def update_installed_ledger(names, client_override:)
86
+ ledger_path = paths.installed_components
87
+ ledger = ledger_path.exist? ? (YAML.safe_load_file(ledger_path) || {}) : {}
88
+ installed = ledger['installed'] ||= []
89
+
90
+ names.each do |name|
91
+ existing = installed.find { |e| e['name'] == name }
92
+ attrs = {
93
+ 'name' => name,
94
+ 'version' => Senren::Rails::VERSION,
95
+ 'installed_at' => Time.now.utc.iso8601,
96
+ 'client' => effective_client_for(registry.fetch(name), client_override)
97
+ }
98
+ if existing
99
+ existing.merge!(attrs.except('installed_at'))
100
+ else
101
+ installed << attrs
102
+ end
103
+ end
104
+
105
+ installed.sort_by! { |e| e['name'] }
106
+ ledger_path.parent.mkpath
107
+ File.write(ledger_path, YAML.dump(ledger))
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,86 @@
1
+ require 'yaml'
2
+
3
+ module Senren
4
+ module Rails
5
+ # Runs a series of checks against the host Rails app and prints a
6
+ # human-readable status report.
7
+ class Doctor
8
+ Result = Struct.new(:label, :ok, :detail) do
9
+ def icon = ok ? '✓' : '✗'
10
+ end
11
+
12
+ attr_reader :paths, :stdout
13
+
14
+ def initialize(paths: HostPaths.new, stdout: $stdout)
15
+ @paths = paths
16
+ @stdout = stdout
17
+ end
18
+
19
+ def run!
20
+ results = []
21
+ results << check('ViewComponent gem available') { defined?(::ViewComponent) }
22
+ results << check('TailwindCSS stylesheet present') { paths.stylesheet_path.exist? }
23
+ results << check('Stimulus directory present') { paths.stimulus_dir.directory? }
24
+ results << check('Turbo gem available') { defined?(::Turbo) || gem_loadable?('turbo-rails') }
25
+ results << check('.senren directory exists') { paths.senren_dir.directory? }
26
+ results << check('.senren/skill.md exists') { paths.skill_file.file? }
27
+ results << check('.senren/registry.yml exists') { paths.registry_mirror.file? }
28
+ results << check('.senren/installed_components.yml exists') { paths.installed_components.file? }
29
+ results << check('public/llms.txt exists') { paths.llms_short.file? }
30
+ results << check('public/llms-full.txt exists') { paths.llms_full.file? }
31
+ results << check('app/components/senren exists') { paths.components_dir.directory? }
32
+ results << check('app/javascript/controllers/senren exists') { paths.stimulus_dir.directory? }
33
+ installed = installed_count
34
+ results << Result.new("#{installed} component(s) installed", installed >= 0, nil)
35
+
36
+ report(results)
37
+ results.all?(&:ok)
38
+ end
39
+
40
+ private
41
+
42
+ def check(label)
43
+ ok = false
44
+ detail = nil
45
+ begin
46
+ ok = !yield.nil?
47
+ rescue StandardError => e
48
+ detail = e.message
49
+ end
50
+ Result.new(label, ok, detail)
51
+ end
52
+
53
+ def gem_loadable?(name)
54
+ Gem::Specification.find_by_name(name)
55
+ true
56
+ rescue Gem::LoadError
57
+ false
58
+ end
59
+
60
+ def installed_count
61
+ return 0 unless paths.installed_components.file?
62
+
63
+ ledger = YAML.safe_load_file(paths.installed_components) || {}
64
+ Array(ledger['installed']).size
65
+ rescue StandardError
66
+ -1
67
+ end
68
+
69
+ def report(results)
70
+ stdout.puts 'Senren Doctor'
71
+ stdout.puts ''
72
+ results.each do |r|
73
+ line = "#{r.icon} #{r.label}"
74
+ line += " -- #{r.detail}" if r.detail
75
+ stdout.puts line
76
+ end
77
+ stdout.puts ''
78
+ if results.all?(&:ok)
79
+ stdout.puts 'No issues found.'
80
+ else
81
+ stdout.puts 'Issues found. Resolve the items marked with ✗.'
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/engine'
4
+
5
+ module Senren
6
+ module Rails
7
+ class Engine < ::Rails::Engine
8
+ isolate_namespace Senren
9
+
10
+ initializer 'senren.rails.load_tasks' do |app|
11
+ rake_path = File.expand_path('../../tasks/senren.rake', __dir__)
12
+ app.paths['lib/tasks'] << rake_path if File.exist?(rake_path)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,36 @@
1
+ require 'pathname'
2
+
3
+ module Senren
4
+ module Rails
5
+ # Resolves canonical paths inside a host Rails application.
6
+ # Accepts an explicit root for tests; defaults to Rails.root when present.
7
+ class HostPaths
8
+ attr_reader :root
9
+
10
+ def initialize(root = nil)
11
+ @root = Pathname.new(root || ::Rails.root).expand_path
12
+ end
13
+
14
+ def senren_dir = root.join('.senren')
15
+ def skill_file = senren_dir.join('skill.md')
16
+ def registry_mirror = senren_dir.join('registry.yml')
17
+ def installed_components = senren_dir.join('installed_components.yml')
18
+ def conventions_file = senren_dir.join('conventions.md')
19
+
20
+ def components_dir = root.join('app', 'components', 'senren')
21
+ def base_component_path = components_dir.join('base_component.rb')
22
+
23
+ def stylesheet_path = root.join('app', 'assets', 'stylesheets', 'senren.css')
24
+
25
+ def stimulus_dir = root.join('app', 'javascript', 'controllers', 'senren')
26
+
27
+ def llms_short = root.join('public', 'llms.txt')
28
+ def llms_full = root.join('public', 'llms-full.txt')
29
+
30
+ def ensure_dirs!
31
+ [senren_dir, components_dir, stimulus_dir,
32
+ stylesheet_path.dirname, llms_short.dirname].each(&:mkpath)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module Senren
6
+ module Rails
7
+ # Idempotent installer that lays down the .senren directory, base
8
+ # component, stylesheet, and initial llms files. Reused by the install
9
+ # generator and by ad-hoc rake task entry points.
10
+ class Installer
11
+ attr_reader :paths, :stdout
12
+
13
+ def initialize(paths: HostPaths.new, stdout: $stdout)
14
+ @paths = paths
15
+ @stdout = stdout
16
+ end
17
+
18
+ def run!(force: false)
19
+ paths.ensure_dirs!
20
+ install_static_files(force: force)
21
+ mirror_registry
22
+ SkillWriter.new(paths: paths).sync!
23
+ LlmsWriter.new(paths: paths).generate!
24
+ print_next_steps
25
+ true
26
+ end
27
+
28
+ INSTALL_GENERATOR_TEMPLATES = File.expand_path(
29
+ '../../generators/senren/install/templates', __dir__
30
+ ).freeze
31
+
32
+ STATIC_FILES = {
33
+ 'conventions.md.tt' => :conventions_file,
34
+ 'installed_components.yml.tt' => :installed_components,
35
+ 'base_component.rb.tt' => :base_component_path,
36
+ 'senren.css.tt' => :stylesheet_path
37
+ }.freeze
38
+
39
+ private
40
+
41
+ def install_static_files(force:)
42
+ STATIC_FILES.each do |template_basename, paths_method|
43
+ src = File.join(INSTALL_GENERATOR_TEMPLATES, template_basename)
44
+ dest = paths.public_send(paths_method)
45
+ copy_template(src, dest, force: force)
46
+ end
47
+ end
48
+
49
+ def copy_template(src, dest, force:)
50
+ unless File.exist?(src)
51
+ stdout.puts " warn template missing: #{src}"
52
+ return
53
+ end
54
+ if File.exist?(dest) && !force
55
+ stdout.puts " skip #{dest}"
56
+ return
57
+ end
58
+ FileUtils.mkdir_p(File.dirname(dest))
59
+ FileUtils.cp(src, dest)
60
+ stdout.puts " copy #{dest}"
61
+ end
62
+
63
+ def mirror_registry
64
+ FileUtils.mkdir_p(paths.registry_mirror.dirname)
65
+ FileUtils.cp(Senren::Rails.registry_path, paths.registry_mirror)
66
+ stdout.puts " mirror #{paths.registry_mirror}"
67
+ end
68
+
69
+ def print_next_steps
70
+ stdout.puts <<~MSG
71
+
72
+ Senren installed. Next steps:
73
+
74
+ bin/rails senren:add button card badge alert
75
+ bin/rails senren:add dialog dropdown_menu
76
+ bin/rails senren:doctor
77
+
78
+ Read .senren/skill.md and .senren/conventions.md to get oriented.
79
+ MSG
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Senren
6
+ module Rails
7
+ # Generates public/llms.txt and public/llms-full.txt for AI discoverability.
8
+ # The entire content of both files is owned by this writer; do not edit by hand.
9
+ class LlmsWriter
10
+ attr_reader :registry, :paths
11
+
12
+ def initialize(registry: Registry.load!, paths: HostPaths.new)
13
+ @registry = registry
14
+ @paths = paths
15
+ end
16
+
17
+ def generate!
18
+ paths.llms_short.parent.mkpath
19
+ paths.llms_full.parent.mkpath
20
+ atomic_write(paths.llms_short, render_short)
21
+ atomic_write(paths.llms_full, render_full)
22
+ [paths.llms_short, paths.llms_full]
23
+ end
24
+
25
+ private
26
+
27
+ def installed
28
+ path = paths.installed_components
29
+ return [] unless path.exist?
30
+
31
+ ledger = YAML.safe_load_file(path) || {}
32
+ Array(ledger['installed']).filter_map { |e| registry.find(e['name']) }
33
+ end
34
+
35
+ def render_short
36
+ names = installed.map(&:name).sort
37
+ <<~TXT
38
+ # Senren UI
39
+
40
+ Generated by senren-ui. Do not edit by hand. Run
41
+ `bin/rails senren:llms:generate` to regenerate.
42
+
43
+ Senren UI is the local Rails UI system used by this application.
44
+ Use `.senren/skill.md` as the primary AI Agent guide.
45
+
46
+ ## Hard Rules
47
+
48
+ - Use Senren components before writing custom HTML.
49
+ - Use ViewComponent for reusable UI.
50
+ - Use Turbo for server state.
51
+ - Use Stimulus only for local behavior.
52
+ - Do not introduce React, Vue, Alpine, or any external state framework.
53
+ - Do not hard-code colors; use semantic Tailwind tokens.
54
+
55
+ ## Important Files
56
+
57
+ - `.senren/skill.md` - centralized AI agent guide
58
+ - `.senren/registry.yml` - mirror of installable components
59
+ - `.senren/installed_components.yml` - what is currently installed
60
+ - `.senren/conventions.md` - Senren conventions for humans and agents
61
+
62
+ ## Installed Components (#{names.size})
63
+
64
+ #{names.map { |n| "- #{n}" }.join("\n")}
65
+ TXT
66
+ end
67
+
68
+ def render_full
69
+ comps = installed.sort_by(&:name)
70
+
71
+ out = []
72
+ out << '# Senren UI - Full Snapshot'
73
+ out << ''
74
+ out << 'Generated by senren-ui. Do not edit by hand. Run'
75
+ out << '`bin/rails senren:llms:generate` to regenerate.'
76
+ out << ''
77
+ out << '## Hard Rules'
78
+ out << ''
79
+ out << '- Use Senren components before writing custom HTML.'
80
+ out << '- Server-rendered HTML first; ViewComponent for reusable UI.'
81
+ out << '- Hotwire (Turbo + Stimulus) is the only client runtime.'
82
+ out << '- TailwindCSS with semantic tokens (`bg-background`, `text-foreground`, ...).'
83
+ out << '- Do not introduce React, Vue, Alpine, or external state frameworks.'
84
+ out << '- Components copied into `app/components/senren/` are owned by this app; edit them directly.'
85
+ out << ''
86
+ out << '## Installed Component Inventory'
87
+ out << ''
88
+
89
+ registry.groups.each do |group|
90
+ group_comps = comps.select { |c| c.category == group['id'] }
91
+ next if group_comps.empty?
92
+
93
+ out << "### #{group['title']}"
94
+ out << ''
95
+ out << group['description'].to_s
96
+ out << ''
97
+ group_comps.each { |c| out << render_component(c) }
98
+ end
99
+
100
+ out << '## Recipes'
101
+ out << ''
102
+ registry.recipes.each do |id, recipe|
103
+ out << "### #{id}"
104
+ out << ''
105
+ out << recipe['description'].to_s
106
+ out << ''
107
+ out << 'Components:'
108
+ recipe['components'].each { |c| out << "- #{c}" }
109
+ out << ''
110
+ end
111
+
112
+ out.join("\n")
113
+ end
114
+
115
+ def render_component(comp)
116
+ ruby_class = "Senren::#{comp.name.split('_').map { |w| w[0].upcase + w[1..] }.join}Component"
117
+ s = []
118
+ s << "#### #{comp.name}#{' (stub)' if comp.stub?}"
119
+ s << ''
120
+ s << "Category: #{comp.category}. " \
121
+ "Client: #{comp.client? ? "yes (#{comp.controller})" : 'no'}. " \
122
+ "Variants: #{comp.variants.empty? ? 'none' : comp.variants.join(', ')}."
123
+ s << ''
124
+ s << "Use for: #{comp.use_for.join('; ')}." unless comp.use_for.empty?
125
+ s << "Avoid: #{comp.avoid.join('; ')}." unless comp.avoid.empty?
126
+ s << ''
127
+ s << 'Rails usage:'
128
+ s << ''
129
+ s << '```erb'
130
+ s << if comp.variants.any?
131
+ "<%= render #{ruby_class}.new(variant: :#{comp.variants.first}) do %>"
132
+ else
133
+ "<%= render #{ruby_class}.new do %>"
134
+ end
135
+ s << ' ...'
136
+ s << '<% end %>'
137
+ s << '```'
138
+ s << ''
139
+ s.join("\n")
140
+ end
141
+
142
+ def atomic_write(path, content)
143
+ tmp = "#{path}.tmp"
144
+ File.write(tmp, content)
145
+ File.rename(tmp, path)
146
+ end
147
+ end
148
+ end
149
+ end