neon_sakura 0.1.4

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 (251) hide show
  1. checksums.yaml +7 -0
  2. data/.ai-reviewer/README.md +182 -0
  3. data/.ai-reviewer/ai-reviewer.sh +56 -0
  4. data/.ai-reviewer/build-system-prompt.sh +136 -0
  5. data/.ai-reviewer/extract-claude-sections.sh +32 -0
  6. data/.ai-reviewer/test-ai-reviewer.sh +40 -0
  7. data/.ai-reviewer-config.yml +190 -0
  8. data/.github/dependabot.yml +12 -0
  9. data/.github/settings.yml +70 -0
  10. data/.github/workflows/ai-pr-review-on-comment.yml +384 -0
  11. data/.github/workflows/ai-pr-review.yml +328 -0
  12. data/.github/workflows/license-check.yml +78 -0
  13. data/.github/workflows/lint.yml +79 -0
  14. data/.github/workflows/security.yml +131 -0
  15. data/.github/workflows/semgrep.yml +26 -0
  16. data/.github/workflows/test.yml +44 -0
  17. data/.gitignore +75 -0
  18. data/.rubocop.yml +33 -0
  19. data/.ruby-version +1 -0
  20. data/.simplecov +14 -0
  21. data/.stylelintignore +10 -0
  22. data/.stylelintrc.json +37 -0
  23. data/AGENTS.md +51 -0
  24. data/CHANGELOG.md +568 -0
  25. data/CLAUDE.md +632 -0
  26. data/Gemfile +8 -0
  27. data/Gemfile.lock +327 -0
  28. data/LICENSE +21 -0
  29. data/README.md +1209 -0
  30. data/Rakefile +25 -0
  31. data/app/assets/images/cherry_blossom.svg +1525 -0
  32. data/app/assets/images/cherry_blossom_tree.png +0 -0
  33. data/app/assets/images/prysm-icon.png +0 -0
  34. data/app/assets/stylesheets/base.css +29 -0
  35. data/app/assets/stylesheets/components.css +1652 -0
  36. data/app/assets/stylesheets/forms.css +152 -0
  37. data/app/assets/stylesheets/loading.css +145 -0
  38. data/app/assets/stylesheets/neon_sakura.css +40 -0
  39. data/app/assets/stylesheets/pagy-tailwind.css +120 -0
  40. data/app/assets/stylesheets/theme-default.css +40 -0
  41. data/app/assets/stylesheets/theme-green.css +84 -0
  42. data/app/assets/stylesheets/theme-purple.css +94 -0
  43. data/app/assets/stylesheets/theme-red.css +84 -0
  44. data/app/assets/stylesheets/utility-borders.css +29 -0
  45. data/app/assets/stylesheets/utility-colors.css +185 -0
  46. data/app/assets/stylesheets/utility-effects.css +123 -0
  47. data/app/assets/stylesheets/utility-gradients.css +158 -0
  48. data/app/assets/stylesheets/utility-layout.css +132 -0
  49. data/app/assets/stylesheets/utility-reset.css +13 -0
  50. data/app/assets/stylesheets/utility-responsive.css +145 -0
  51. data/app/assets/stylesheets/utility-sizing.css +99 -0
  52. data/app/assets/stylesheets/utility-spacing.css +174 -0
  53. data/app/assets/stylesheets/utility-typography.css +97 -0
  54. data/app/controllers/errors_controller.rb +120 -0
  55. data/app/controllers/style_guide_controller.rb +117 -0
  56. data/app/helpers/errors_helper.rb +12 -0
  57. data/app/helpers/neon_sakura/navbar_helper.rb +43 -0
  58. data/app/helpers/style_guide_helper.rb +36 -0
  59. data/app/javascript/neon_sakura/dropdown.js +22 -0
  60. data/app/javascript/neon_sakura/navbar.js +71 -0
  61. data/app/javascript/neon_sakura/theme_switcher.js +187 -0
  62. data/app/views/errors/show.html.erb +105 -0
  63. data/app/views/layouts/error.html.erb +19 -0
  64. data/app/views/layouts/mission_control/jobs/_application_selection.html.erb +14 -0
  65. data/app/views/layouts/mission_control/jobs/_navigation.html.erb +21 -0
  66. data/app/views/layouts/mission_control/jobs/application.html.erb +453 -0
  67. data/app/views/layouts/style_guide.html.erb +416 -0
  68. data/app/views/shared/_file_upload.html.erb +184 -0
  69. data/app/views/shared/_footer.html.erb +23 -0
  70. data/app/views/shared/_header.html.erb +42 -0
  71. data/app/views/shared/_navbar.html.erb +306 -0
  72. data/app/views/shared/_profile_image_selector.html.erb +165 -0
  73. data/app/views/shared/_theme_switcher.html.erb +64 -0
  74. data/app/views/shared/icons/_adjustments.html.erb +10 -0
  75. data/app/views/shared/icons/_alert_circle.html.erb +3 -0
  76. data/app/views/shared/icons/_alert_triangle.html.erb +3 -0
  77. data/app/views/shared/icons/_archive.html.erb +3 -0
  78. data/app/views/shared/icons/_arrow_down.html.erb +3 -0
  79. data/app/views/shared/icons/_arrow_left.html.erb +3 -0
  80. data/app/views/shared/icons/_arrow_up.html.erb +3 -0
  81. data/app/views/shared/icons/_arrows_pointing_in.html.erb +10 -0
  82. data/app/views/shared/icons/_arrows_pointing_out.html.erb +10 -0
  83. data/app/views/shared/icons/_artemis_logo.html.erb +26 -0
  84. data/app/views/shared/icons/_auth_banner.html.erb +1 -0
  85. data/app/views/shared/icons/_bars.html.erb +10 -0
  86. data/app/views/shared/icons/_bell.html.erb +3 -0
  87. data/app/views/shared/icons/_book.html.erb +3 -0
  88. data/app/views/shared/icons/_bookmark.html.erb +3 -0
  89. data/app/views/shared/icons/_box.html.erb +3 -0
  90. data/app/views/shared/icons/_brain.html.erb +3 -0
  91. data/app/views/shared/icons/_briefcase.html.erb +3 -0
  92. data/app/views/shared/icons/_calendar.html.erb +3 -0
  93. data/app/views/shared/icons/_camera.html.erb +4 -0
  94. data/app/views/shared/icons/_chart_bar.html.erb +3 -0
  95. data/app/views/shared/icons/_chart_line.html.erb +10 -0
  96. data/app/views/shared/icons/_chart_pie.html.erb +11 -0
  97. data/app/views/shared/icons/_chat.html.erb +3 -0
  98. data/app/views/shared/icons/_check.html.erb +3 -0
  99. data/app/views/shared/icons/_check_circle.html.erb +3 -0
  100. data/app/views/shared/icons/_cherry_blossom.html.erb +1516 -0
  101. data/app/views/shared/icons/_cherry_blossom_silhouette.html.erb +1016 -0
  102. data/app/views/shared/icons/_cherry_blossom_single_flower.html.erb +1125 -0
  103. data/app/views/shared/icons/_cherry_blossom_tree.html.erb +159 -0
  104. data/app/views/shared/icons/_chevron_down.html.erb +3 -0
  105. data/app/views/shared/icons/_chevron_right.html.erb +9 -0
  106. data/app/views/shared/icons/_clipboard.html.erb +3 -0
  107. data/app/views/shared/icons/_clock.html.erb +3 -0
  108. data/app/views/shared/icons/_close.html.erb +3 -0
  109. data/app/views/shared/icons/_cog.html.erb +4 -0
  110. data/app/views/shared/icons/_crop.html.erb +10 -0
  111. data/app/views/shared/icons/_crown.html.erb +3 -0
  112. data/app/views/shared/icons/_disc.html.erb +3 -0
  113. data/app/views/shared/icons/_download.html.erb +3 -0
  114. data/app/views/shared/icons/_dragonfly.html.erb +58 -0
  115. data/app/views/shared/icons/_duplicate.html.erb +4 -0
  116. data/app/views/shared/icons/_edit.html.erb +3 -0
  117. data/app/views/shared/icons/_envelope.html.erb +3 -0
  118. data/app/views/shared/icons/_eraser.html.erb +10 -0
  119. data/app/views/shared/icons/_external_link.html.erb +3 -0
  120. data/app/views/shared/icons/_eye.html.erb +4 -0
  121. data/app/views/shared/icons/_file_csv.html.erb +10 -0
  122. data/app/views/shared/icons/_file_export.html.erb +10 -0
  123. data/app/views/shared/icons/_file_image.html.erb +10 -0
  124. data/app/views/shared/icons/_file_import.html.erb +10 -0
  125. data/app/views/shared/icons/_file_question.html.erb +6 -0
  126. data/app/views/shared/icons/_film.html.erb +3 -0
  127. data/app/views/shared/icons/_filter.html.erb +3 -0
  128. data/app/views/shared/icons/_folder.html.erb +3 -0
  129. data/app/views/shared/icons/_folder_open.html.erb +3 -0
  130. data/app/views/shared/icons/_folder_plus.html.erb +3 -0
  131. data/app/views/shared/icons/_globe.html.erb +3 -0
  132. data/app/views/shared/icons/_google.html.erb +11 -0
  133. data/app/views/shared/icons/_heart.html.erb +3 -0
  134. data/app/views/shared/icons/_heart_broken.html.erb +11 -0
  135. data/app/views/shared/icons/_heart_pulse.html.erb +4 -0
  136. data/app/views/shared/icons/_history.html.erb +11 -0
  137. data/app/views/shared/icons/_home.html.erb +10 -0
  138. data/app/views/shared/icons/_image.html.erb +3 -0
  139. data/app/views/shared/icons/_inbox.html.erb +3 -0
  140. data/app/views/shared/icons/_info_circle.html.erb +10 -0
  141. data/app/views/shared/icons/_key.html.erb +3 -0
  142. data/app/views/shared/icons/_layers.html.erb +10 -0
  143. data/app/views/shared/icons/_lightbulb.html.erb +10 -0
  144. data/app/views/shared/icons/_lightning.html.erb +3 -0
  145. data/app/views/shared/icons/_list.html.erb +3 -0
  146. data/app/views/shared/icons/_lock.html.erb +3 -0
  147. data/app/views/shared/icons/_logout.html.erb +3 -0
  148. data/app/views/shared/icons/_magazine.html.erb +3 -0
  149. data/app/views/shared/icons/_magic.html.erb +3 -0
  150. data/app/views/shared/icons/_minus.html.erb +10 -0
  151. data/app/views/shared/icons/_mobile.html.erb +10 -0
  152. data/app/views/shared/icons/_moon.html.erb +3 -0
  153. data/app/views/shared/icons/_network.html.erb +10 -0
  154. data/app/views/shared/icons/_new_item_banner.html.erb +1 -0
  155. data/app/views/shared/icons/_ouroboros.html.erb +24 -0
  156. data/app/views/shared/icons/_package.html.erb +3 -0
  157. data/app/views/shared/icons/_palette.html.erb +3 -0
  158. data/app/views/shared/icons/_paper_plane.html.erb +10 -0
  159. data/app/views/shared/icons/_photo.html.erb +10 -0
  160. data/app/views/shared/icons/_play.html.erb +4 -0
  161. data/app/views/shared/icons/_plus.html.erb +3 -0
  162. data/app/views/shared/icons/_pocket.html.erb +11 -0
  163. data/app/views/shared/icons/_prysm-icon.html.erb +34 -0
  164. data/app/views/shared/icons/_prysm.html.erb +13 -0
  165. data/app/views/shared/icons/_pushbullet-1.html.erb +29 -0
  166. data/app/views/shared/icons/_pushbullet-2.html.erb +2 -0
  167. data/app/views/shared/icons/_puzzle.html.erb +10 -0
  168. data/app/views/shared/icons/_qrcode.html.erb +3 -0
  169. data/app/views/shared/icons/_question.html.erb +3 -0
  170. data/app/views/shared/icons/_receipt.html.erb +10 -0
  171. data/app/views/shared/icons/_redo.html.erb +3 -0
  172. data/app/views/shared/icons/_refresh.html.erb +3 -0
  173. data/app/views/shared/icons/_rocket.html.erb +10 -0
  174. data/app/views/shared/icons/_rss.html.erb +3 -0
  175. data/app/views/shared/icons/_save.html.erb +3 -0
  176. data/app/views/shared/icons/_search.html.erb +3 -0
  177. data/app/views/shared/icons/_search_minus.html.erb +10 -0
  178. data/app/views/shared/icons/_search_plus.html.erb +10 -0
  179. data/app/views/shared/icons/_server_error.html.erb +6 -0
  180. data/app/views/shared/icons/_share.html.erb +3 -0
  181. data/app/views/shared/icons/_shield_check.html.erb +3 -0
  182. data/app/views/shared/icons/_sign_in.html.erb +3 -0
  183. data/app/views/shared/icons/_spinner.html.erb +4 -0
  184. data/app/views/shared/icons/_star.html.erb +3 -0
  185. data/app/views/shared/icons/_store.html.erb +10 -0
  186. data/app/views/shared/icons/_sun.html.erb +3 -0
  187. data/app/views/shared/icons/_sync.html.erb +3 -0
  188. data/app/views/shared/icons/_table.html.erb +3 -0
  189. data/app/views/shared/icons/_tag.html.erb +3 -0
  190. data/app/views/shared/icons/_tags.html.erb +11 -0
  191. data/app/views/shared/icons/_tools.html.erb +4 -0
  192. data/app/views/shared/icons/_trash.html.erb +3 -0
  193. data/app/views/shared/icons/_undo.html.erb +3 -0
  194. data/app/views/shared/icons/_unlock.html.erb +3 -0
  195. data/app/views/shared/icons/_upload.html.erb +3 -0
  196. data/app/views/shared/icons/_user.html.erb +3 -0
  197. data/app/views/shared/icons/_user_circle.html.erb +10 -0
  198. data/app/views/shared/icons/_user_plus.html.erb +10 -0
  199. data/app/views/shared/icons/_video.html.erb +3 -0
  200. data/app/views/shared/icons/_wrench.html.erb +11 -0
  201. data/app/views/style_guide/index.html.erb +77 -0
  202. data/app/views/style_guide/sections/_alerts.html.erb +114 -0
  203. data/app/views/style_guide/sections/_badges.html.erb +78 -0
  204. data/app/views/style_guide/sections/_buttons.html.erb +130 -0
  205. data/app/views/style_guide/sections/_cards.html.erb +84 -0
  206. data/app/views/style_guide/sections/_colors.html.erb +106 -0
  207. data/app/views/style_guide/sections/_file_upload.html.erb +135 -0
  208. data/app/views/style_guide/sections/_forms.html.erb +129 -0
  209. data/app/views/style_guide/sections/_gradients.html.erb +253 -0
  210. data/app/views/style_guide/sections/_header.html.erb +12 -0
  211. data/app/views/style_guide/sections/_icons.html.erb +55 -0
  212. data/app/views/style_guide/sections/_images.html.erb +40 -0
  213. data/app/views/style_guide/sections/_loading.html.erb +242 -0
  214. data/app/views/style_guide/sections/_pagination.html.erb +212 -0
  215. data/app/views/style_guide/sections/_profile_components.html.erb +203 -0
  216. data/app/views/style_guide/sections/_theme_switcher.html.erb +72 -0
  217. data/app/views/style_guide/sections/_typography.html.erb +65 -0
  218. data/bin/ai-optimize-claude-md +540 -0
  219. data/bin/ai-review-local +345 -0
  220. data/bin/ai-security-review +585 -0
  221. data/bin/brakeman +9 -0
  222. data/bin/install-hooks +57 -0
  223. data/bin/rake +7 -0
  224. data/bin/rubocop +10 -0
  225. data/bin/verify_setup.rb +31 -0
  226. data/config/brakeman.ignore +28 -0
  227. data/config/initializers/neon_sakura.rb +15 -0
  228. data/config/license_overrides.yml +13 -0
  229. data/config/routes.rb +21 -0
  230. data/config/theme_mappings.yml +61 -0
  231. data/docs/PRYSM_ASSETS.md +210 -0
  232. data/docs/plans/extract_ai_reviewer_plan.md +151 -0
  233. data/docs/plans/neon_sakura_gem_plan.md +138 -0
  234. data/lib/neon_sakura/configuration.rb +94 -0
  235. data/lib/neon_sakura/engine.rb +48 -0
  236. data/lib/neon_sakura/icon_helper.rb +54 -0
  237. data/lib/neon_sakura/profile_helper.rb +24 -0
  238. data/lib/neon_sakura/stylesheet_helper.rb +40 -0
  239. data/lib/neon_sakura/theme_helper.rb +63 -0
  240. data/lib/neon_sakura/theme_importer.rb +112 -0
  241. data/lib/neon_sakura/version.rb +5 -0
  242. data/lib/neon_sakura.rb +13 -0
  243. data/neon_sakura.gemspec +50 -0
  244. data/package.json +18 -0
  245. data/scripts/git-hooks/post-merge +132 -0
  246. data/scripts/git-hooks/pre-commit +123 -0
  247. data/scripts/git-hooks/pre-push +127 -0
  248. data/scripts/license-check.rb +587 -0
  249. data/settings.local.json +12 -0
  250. data/yarn.lock +778 -0
  251. metadata +503 -0
@@ -0,0 +1,416 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" <%= theme_data_attributes %>>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title><%= @page_title || "Style Guide" %> | Neon Sakura</title>
7
+
8
+ <!-- Favicon: Palette icon -->
9
+ <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23a855f7' stroke-width='1.5'><path stroke-linecap='round' stroke-linejoin='round' d='M4.098 19.902a3.75 3.75 0 0 0 5.304 0l6.401-6.402M6.75 21A3.75 3.75 0 0 1 3 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 0 0 3.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008Z'/></svg>">
10
+
11
+ <%= neon_sakura_stylesheets %>
12
+
13
+ <!-- Pagy JavaScript for interactive pagination -->
14
+ <% if defined?(Pagy) %>
15
+ <script src="https://unpkg.com/pagy@43/javascripts/pagy.min.js"></script>
16
+ <% end %>
17
+
18
+ <style>
19
+ /* Inline styles for code examples display */
20
+ .code-example-wrapper {
21
+ margin: 1.5rem 0;
22
+ border: 1px solid var(--color-border);
23
+ border-radius: 0.5rem;
24
+ overflow: hidden;
25
+ }
26
+
27
+ .code-example-preview {
28
+ padding: 2rem;
29
+ background-color: var(--color-surface);
30
+ border-bottom: 1px solid var(--color-border);
31
+ }
32
+
33
+ .code-example-code {
34
+ background-color: var(--color-background);
35
+ padding: 1rem;
36
+ }
37
+
38
+ .code-example-code pre {
39
+ margin: 0;
40
+ overflow-x: auto;
41
+ font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
42
+ font-size: 0.875rem;
43
+ line-height: 1.5;
44
+ color: var(--color-text-secondary);
45
+ }
46
+
47
+ .section-divider {
48
+ margin: 3rem 0;
49
+ border-top: 2px solid var(--color-border);
50
+ }
51
+
52
+ .sticky-header {
53
+ position: sticky;
54
+ top: 0;
55
+ z-index: 10;
56
+ background-color: var(--color-background);
57
+ border-bottom: 1px solid var(--color-border);
58
+ padding-bottom: 0.2rem;
59
+ margin-bottom: 1.5rem;
60
+ }
61
+ </style>
62
+ </head>
63
+
64
+ <body class="bg-background text-text-primary min-h-screen">
65
+ <!-- Sticky Navbar (always sticky for style guide) -->
66
+ <nav class="sticky-header top-0 z-50 bg-surface border-b border-border shadow-lg">
67
+ <div class="container mx-auto px-4 max-w-7xl">
68
+ <div class="flex items-center justify-between py-2">
69
+ <!-- Left: Back link and title -->
70
+ <div class="flex items-center gap-3">
71
+ <%= link_to root_path, class: "text-white hover:text-accent transition-colors flex items-center gap-2 px-2 py-1 rounded hover:bg-background" do %>
72
+ <%= render_theme_icon("arrow_left", css_class: "w-4 h-4") %>
73
+ <span class="text-sm font-medium">Back</span>
74
+ <% end %>
75
+ <span class="text-border">|</span>
76
+ <h1 class="text-base font-bold bg-gradient-to-r from-accent to-notification bg-clip-text text-transparent">
77
+ Style Guide
78
+ </h1>
79
+ </div>
80
+
81
+ <!-- Right: Compact theme selector -->
82
+ <div class="flex items-center gap-2">
83
+ <% @available_themes.each do |theme| %>
84
+ <%
85
+ is_current = @current_theme[:name] == theme[:name] && @current_theme[:mode] == theme[:mode]
86
+ %>
87
+ <button
88
+ type="button"
89
+ data-theme-item
90
+ data-theme-name="<%= theme[:name] %>"
91
+ data-theme-mode="<%= theme[:mode] %>"
92
+ class="px-2 py-1 text-xs rounded border transition-all <%= is_current ? 'border-accent bg-accent font-semibold' : 'border-border bg-surface hover:border-accent' %>"
93
+ style="<%= is_current ? 'color: white;' : 'color: var(--color-text-primary);' %>"
94
+ aria-label="Switch to <%= theme[:label] %>"
95
+ aria-pressed="<%= is_current %>"
96
+ title="<%= theme[:label] %>"
97
+ >
98
+ <% if theme[:mode] == 'light' %>
99
+ <%= render_theme_icon("sun", css_class: "w-3 h-3") %>
100
+ <% else %>
101
+ <%= render_theme_icon("moon", css_class: "w-3 h-3") %>
102
+ <% end %>
103
+ </button>
104
+ <% end %>
105
+ </div>
106
+ </div>
107
+ </div>
108
+ </nav>
109
+
110
+ <script>
111
+ // Navbar theme switcher
112
+ document.addEventListener('DOMContentLoaded', function() {
113
+ const buttons = document.querySelectorAll('nav [data-theme-item]');
114
+
115
+ buttons.forEach(button => {
116
+ button.addEventListener('click', function() {
117
+ const themeName = this.dataset.themeName;
118
+ const themeMode = this.dataset.themeMode;
119
+
120
+ document.documentElement.setAttribute('data-theme-name', themeName);
121
+ document.documentElement.setAttribute('data-theme-mode', themeMode);
122
+ localStorage.setItem('neon_sakura_theme_name', themeName);
123
+ localStorage.setItem('neon_sakura_theme_mode', themeMode);
124
+
125
+ buttons.forEach(btn => {
126
+ const isActive = btn.dataset.themeName === themeName && btn.dataset.themeMode === themeMode;
127
+ if (isActive) {
128
+ btn.classList.add('border-accent', 'bg-accent', 'font-semibold');
129
+ btn.classList.remove('border-border', 'bg-surface', 'hover:border-accent');
130
+ btn.style.color = 'white';
131
+ } else {
132
+ btn.classList.remove('border-accent', 'bg-accent', 'font-semibold');
133
+ btn.classList.add('border-border', 'bg-surface', 'hover:border-accent');
134
+ btn.style.color = 'var(--color-text-primary)';
135
+ }
136
+ });
137
+ });
138
+ });
139
+ });
140
+ </script>
141
+
142
+ <!-- Main Content -->
143
+ <div class="container mx-auto px-4 py-8 max-w-7xl">
144
+ <%= yield %>
145
+ </div>
146
+
147
+ <!-- Modal for Icons and Images -->
148
+ <div id="zoom-modal" class="modal-overlay">
149
+ <div class="modal-backdrop"></div>
150
+ <div class="modal-container">
151
+ <button id="modal-close" class="modal-close-button" aria-label="Close modal">
152
+ <%= render_theme_icon("close", css_class: "w-6 h-6") %>
153
+ </button>
154
+ <!-- Zoom Controls -->
155
+ <div id="zoom-controls" class="zoom-controls">
156
+ <button id="zoom-in" class="zoom-control-button" aria-label="Zoom in" title="Zoom in">
157
+ <%= render_theme_icon("plus", css_class: "w-5 h-5") %>
158
+ </button>
159
+ <button id="zoom-out" class="zoom-control-button" aria-label="Zoom out" title="Zoom out">
160
+ <%= render_theme_icon("minus", css_class: "w-5 h-5") %>
161
+ </button>
162
+ <button id="zoom-reset" class="zoom-control-button" aria-label="Reset zoom" title="Reset zoom (100%)">
163
+ <%= render_theme_icon("arrows_pointing_in", css_class: "w-5 h-5") %>
164
+ </button>
165
+ <span id="zoom-level" class="zoom-level-text">100%</span>
166
+ </div>
167
+ <div id="modal-content" class="modal-content">
168
+ <!-- Content will be inserted here -->
169
+ </div>
170
+ </div>
171
+ </div>
172
+
173
+ <script>
174
+ // Modal functionality for icons and images with zoom
175
+ document.addEventListener('DOMContentLoaded', function() {
176
+ const modal = document.getElementById('zoom-modal');
177
+ const modalContent = document.getElementById('modal-content');
178
+ const modalClose = document.getElementById('modal-close');
179
+ const backdrop = modal.querySelector('.modal-backdrop');
180
+ const zoomInBtn = document.getElementById('zoom-in');
181
+ const zoomOutBtn = document.getElementById('zoom-out');
182
+ const zoomResetBtn = document.getElementById('zoom-reset');
183
+ const zoomLevelText = document.getElementById('zoom-level');
184
+
185
+ let zoomLevel = 1;
186
+ let panX = 0;
187
+ let panY = 0;
188
+ let isDragging = false;
189
+ let dragStartX = 0;
190
+ let dragStartY = 0;
191
+ let zoomableElement = null;
192
+
193
+ const MIN_ZOOM = 0.5;
194
+ const MAX_ZOOM = 5;
195
+ const ZOOM_STEP = 0.25;
196
+
197
+ function updateZoom() {
198
+ if (!zoomableElement) return;
199
+
200
+ zoomableElement.style.transform = `translate(${panX}px, ${panY}px) scale(${zoomLevel})`;
201
+ zoomLevelText.textContent = Math.round(zoomLevel * 100) + '%';
202
+
203
+ // Disable/enable buttons based on zoom level
204
+ zoomInBtn.disabled = zoomLevel >= MAX_ZOOM;
205
+ zoomOutBtn.disabled = zoomLevel <= MIN_ZOOM;
206
+ }
207
+
208
+ function zoomIn() {
209
+ if (zoomLevel < MAX_ZOOM) {
210
+ zoomLevel = Math.min(MAX_ZOOM, zoomLevel + ZOOM_STEP);
211
+ updateZoom();
212
+ }
213
+ }
214
+
215
+ function zoomOut() {
216
+ if (zoomLevel > MIN_ZOOM) {
217
+ zoomLevel = Math.max(MIN_ZOOM, zoomLevel - ZOOM_STEP);
218
+ updateZoom();
219
+ }
220
+ }
221
+
222
+ function resetZoom() {
223
+ zoomLevel = 1;
224
+ panX = 0;
225
+ panY = 0;
226
+ updateZoom();
227
+ }
228
+
229
+ function openModal() {
230
+ modal.classList.add('modal-open');
231
+ document.body.style.overflow = 'hidden';
232
+ resetZoom();
233
+ }
234
+
235
+ function closeModal() {
236
+ modal.classList.remove('modal-open');
237
+ document.body.style.overflow = '';
238
+ resetZoom();
239
+ }
240
+
241
+ // Icon modal click handlers
242
+ document.querySelectorAll('[data-icon-modal]').forEach(iconCard => {
243
+ iconCard.addEventListener('click', function() {
244
+ const iconName = this.dataset.iconName;
245
+
246
+ // Find the SVG element in the clicked card
247
+ const svgElement = this.querySelector('svg');
248
+ if (!svgElement) {
249
+ console.error('No SVG found for icon:', iconName);
250
+ return;
251
+ }
252
+
253
+ // Clone the SVG and update it for modal display
254
+ const clonedSvg = svgElement.cloneNode(true);
255
+
256
+ // Set explicit dimensions for visibility
257
+ clonedSvg.style.width = '256px';
258
+ clonedSvg.style.height = '256px';
259
+ clonedSvg.style.display = 'block';
260
+
261
+ // Preserve color classes but remove size classes
262
+ const classList = clonedSvg.className.baseVal || clonedSvg.getAttribute('class') || '';
263
+ const newClasses = classList
264
+ .split(' ')
265
+ .filter(c => !c.match(/^(w-|h-)/)) // Remove size classes
266
+ .join(' ');
267
+
268
+ // Set the cleaned classes
269
+ if (clonedSvg.className.baseVal !== undefined) {
270
+ clonedSvg.className.baseVal = newClasses;
271
+ } else {
272
+ clonedSvg.setAttribute('class', newClasses);
273
+ }
274
+
275
+ // Create the modal content
276
+ const modalWrapper = document.createElement('div');
277
+ modalWrapper.className = 'zoomable-wrapper';
278
+ modalWrapper.id = 'zoomable-wrapper';
279
+
280
+ const modalContentDiv = document.createElement('div');
281
+ modalContentDiv.className = 'zoomable-content';
282
+ modalContentDiv.id = 'zoomable-content';
283
+
284
+ const iconContainer = document.createElement('div');
285
+ iconContainer.className = 'flex justify-center items-center p-8 bg-surface rounded-lg border border-border';
286
+ iconContainer.style.minHeight = '300px';
287
+ iconContainer.appendChild(clonedSvg);
288
+
289
+ modalContentDiv.appendChild(iconContainer);
290
+ modalWrapper.appendChild(modalContentDiv);
291
+
292
+ // Clear and populate modal content
293
+ modalContent.innerHTML = '';
294
+ modalContent.appendChild(modalWrapper);
295
+
296
+ // Add icon name and code example
297
+ const heading = document.createElement('h3');
298
+ heading.className = 'text-2xl font-bold font-mono text-accent mt-4';
299
+ heading.textContent = iconName;
300
+
301
+ const codeBlock = document.createElement('code');
302
+ codeBlock.className = 'px-4 py-2 bg-surface border border-border rounded text-sm';
303
+ codeBlock.textContent = '<' + '%= render_theme_icon("' + iconName + '", css_class: "w-6 h-6") %' + '>';
304
+
305
+ modalContent.appendChild(heading);
306
+ modalContent.appendChild(codeBlock);
307
+
308
+ zoomableElement = document.getElementById('zoomable-content');
309
+ setupDragHandlers();
310
+ openModal();
311
+ });
312
+ });
313
+
314
+ // Image modal click handlers
315
+ document.querySelectorAll('[data-image-modal]').forEach(imageCard => {
316
+ imageCard.addEventListener('click', function() {
317
+ const imageSrc = this.dataset.imageSrc;
318
+ const imageName = this.dataset.imageName;
319
+
320
+ modalContent.innerHTML = `
321
+ <div class="zoomable-wrapper" id="zoomable-wrapper">
322
+ <div class="zoomable-content" id="zoomable-content">
323
+ <div class="flex justify-center items-center p-8 bg-surface rounded-lg border border-border">
324
+ <img src="${imageSrc}" alt="${imageName}" style="max-width: 100%; max-height: 60vh; width: auto; height: auto; object-fit: contain;">
325
+ </div>
326
+ </div>
327
+ </div>
328
+ <h3 class="text-2xl font-bold font-mono text-accent mt-4">${imageName}</h3>
329
+ <code class="px-4 py-2 bg-surface border border-border rounded text-sm">
330
+ &lt;%= image_tag "${imageName}", class: "w-full" %&gt;
331
+ </code>
332
+ `;
333
+
334
+ zoomableElement = document.getElementById('zoomable-content');
335
+ setupDragHandlers();
336
+ openModal();
337
+ });
338
+ });
339
+
340
+ function setupDragHandlers() {
341
+ const wrapper = document.getElementById('zoomable-wrapper');
342
+ if (!wrapper) return;
343
+
344
+ wrapper.addEventListener('mousedown', function(e) {
345
+ if (zoomLevel > 1) {
346
+ isDragging = true;
347
+ dragStartX = e.clientX - panX;
348
+ dragStartY = e.clientY - panY;
349
+ wrapper.classList.add('dragging');
350
+ e.preventDefault();
351
+ }
352
+ });
353
+
354
+ document.addEventListener('mousemove', function(e) {
355
+ if (isDragging) {
356
+ panX = e.clientX - dragStartX;
357
+ panY = e.clientY - dragStartY;
358
+ updateZoom();
359
+ }
360
+ });
361
+
362
+ document.addEventListener('mouseup', function() {
363
+ if (isDragging) {
364
+ isDragging = false;
365
+ const wrapper = document.getElementById('zoomable-wrapper');
366
+ if (wrapper) wrapper.classList.remove('dragging');
367
+ }
368
+ });
369
+
370
+ // Mouse wheel zoom
371
+ wrapper.addEventListener('wheel', function(e) {
372
+ e.preventDefault();
373
+
374
+ if (e.deltaY < 0) {
375
+ zoomIn();
376
+ } else {
377
+ zoomOut();
378
+ }
379
+ }, { passive: false });
380
+ }
381
+
382
+ // Zoom button handlers
383
+ zoomInBtn.addEventListener('click', zoomIn);
384
+ zoomOutBtn.addEventListener('click', zoomOut);
385
+ zoomResetBtn.addEventListener('click', resetZoom);
386
+
387
+ // Close modal handlers
388
+ modalClose.addEventListener('click', closeModal);
389
+ backdrop.addEventListener('click', closeModal);
390
+
391
+ // ESC key to close modal
392
+ document.addEventListener('keydown', function(e) {
393
+ if (e.key === 'Escape' && modal.classList.contains('modal-open')) {
394
+ closeModal();
395
+ }
396
+ });
397
+
398
+ // Keyboard shortcuts for zoom
399
+ document.addEventListener('keydown', function(e) {
400
+ if (modal.classList.contains('modal-open')) {
401
+ if (e.key === '+' || e.key === '=') {
402
+ e.preventDefault();
403
+ zoomIn();
404
+ } else if (e.key === '-' || e.key === '_') {
405
+ e.preventDefault();
406
+ zoomOut();
407
+ } else if (e.key === '0') {
408
+ e.preventDefault();
409
+ resetZoom();
410
+ }
411
+ }
412
+ });
413
+ });
414
+ </script>
415
+ </body>
416
+ </html>
@@ -0,0 +1,184 @@
1
+ <%
2
+ # File Upload Component
3
+ # Provides drag-and-drop file upload with preview support
4
+ #
5
+ # Parameters:
6
+ # field_name: Name attribute for the file input (default: "file")
7
+ # accept: Accepted file types (default: "*/*")
8
+ # max_size: Maximum file size in MB (default: 10)
9
+ # preview: Show image preview (default: false)
10
+ # css_class: Additional CSS classes for container
11
+ # multiple: Allow multiple file selection (default: false)
12
+
13
+ field_name = local_assigns[:field_name] || "file"
14
+ accept = local_assigns[:accept] || "*/*"
15
+ max_size = local_assigns[:max_size] || 10
16
+ preview = local_assigns[:preview] || false
17
+ css_class = local_assigns[:css_class] || ""
18
+ multiple = local_assigns[:multiple] || false
19
+ unique_id = "file-upload-#{SecureRandom.hex(4)}"
20
+ %>
21
+
22
+ <div class="file-upload-container <%= css_class %>" data-controller="file-upload">
23
+ <div class="file-upload-zone" id="<%= unique_id %>-zone" data-file-upload-target="zone">
24
+ <input type="file"
25
+ name="<%= field_name %><%= multiple ? '[]' : '' %>"
26
+ id="<%= unique_id %>-input"
27
+ accept="<%= accept %>"
28
+ class="file-upload-input"
29
+ <%= 'multiple' if multiple %>
30
+ data-file-upload-target="input"
31
+ data-action="change->file-upload#handleFileSelect"
32
+ hidden>
33
+
34
+ <div class="file-upload-prompt" data-file-upload-target="prompt">
35
+ <%= render "shared/icons/upload", css_class: "w-12 h-12 mb-3 text-text-muted" %>
36
+ <p class="text-text-primary font-medium">Drag and drop <%= multiple ? 'files' : 'file' %> here</p>
37
+ <p class="text-text-muted text-sm">or</p>
38
+ <button type="button"
39
+ class="btn btn-outline-secondary mt-2"
40
+ onclick="document.getElementById('<%= unique_id %>-input').click()">
41
+ Browse Files
42
+ </button>
43
+ <p class="text-text-muted text-xs mt-3">Max size: <%= max_size %>MB<%= ' each' if multiple %></p>
44
+ <% if accept != "*/*" %>
45
+ <p class="text-text-muted text-xs">Accepted types: <%= accept %></p>
46
+ <% end %>
47
+ </div>
48
+
49
+ <% if preview %>
50
+ <div class="file-upload-preview-container hidden" data-file-upload-target="previewContainer">
51
+ <img src="" alt="Preview" class="file-upload-preview" data-file-upload-target="preview">
52
+ <button type="button"
53
+ class="btn btn-sm btn-outline-secondary mt-2"
54
+ data-action="click->file-upload#clearFile">
55
+ Clear
56
+ </button>
57
+ </div>
58
+ <% end %>
59
+
60
+ <div class="file-upload-status hidden" data-file-upload-target="status">
61
+ <p class="text-text-primary" data-file-upload-target="statusText"></p>
62
+ </div>
63
+ </div>
64
+
65
+ <div class="file-upload-error hidden text-alert text-sm mt-2" data-file-upload-target="error"></div>
66
+ </div>
67
+
68
+ <script>
69
+ // File upload Stimulus controller inline (temporary, should move to separate file)
70
+ if (!window.fileUploadControllerRegistered) {
71
+ window.fileUploadControllerRegistered = true;
72
+
73
+ if (typeof Stimulus !== 'undefined') {
74
+ Stimulus.register("file-upload", class extends Stimulus.Controller {
75
+ static targets = ["zone", "input", "prompt", "previewContainer", "preview", "status", "statusText", "error"]
76
+
77
+ connect() {
78
+ this.maxSize = <%= max_size * 1024 * 1024 %>; // Convert MB to bytes
79
+ this.setupDragAndDrop();
80
+ }
81
+
82
+ setupDragAndDrop() {
83
+ const zone = this.zoneTarget;
84
+
85
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
86
+ zone.addEventListener(eventName, this.preventDefaults.bind(this), false);
87
+ });
88
+
89
+ ['dragenter', 'dragover'].forEach(eventName => {
90
+ zone.addEventListener(eventName, this.highlight.bind(this), false);
91
+ });
92
+
93
+ ['dragleave', 'drop'].forEach(eventName => {
94
+ zone.addEventListener(eventName, this.unhighlight.bind(this), false);
95
+ });
96
+
97
+ zone.addEventListener('drop', this.handleDrop.bind(this), false);
98
+ }
99
+
100
+ preventDefaults(e) {
101
+ e.preventDefault();
102
+ e.stopPropagation();
103
+ }
104
+
105
+ highlight(e) {
106
+ this.zoneTarget.classList.add('drag-over');
107
+ }
108
+
109
+ unhighlight(e) {
110
+ this.zoneTarget.classList.remove('drag-over');
111
+ }
112
+
113
+ handleDrop(e) {
114
+ const dt = e.dataTransfer;
115
+ const files = dt.files;
116
+ this.inputTarget.files = files;
117
+ this.handleFileSelect();
118
+ }
119
+
120
+ handleFileSelect() {
121
+ const files = this.inputTarget.files;
122
+ this.hideError();
123
+
124
+ if (files.length === 0) return;
125
+
126
+ // Validate file size
127
+ for (let file of files) {
128
+ if (file.size > this.maxSize) {
129
+ this.showError(`File "${file.name}" exceeds maximum size of <%= max_size %>MB`);
130
+ this.inputTarget.value = '';
131
+ return;
132
+ }
133
+ }
134
+
135
+ <% if preview %>
136
+ // Show preview for first image file
137
+ const file = files[0];
138
+ if (file && file.type.startsWith('image/')) {
139
+ const reader = new FileReader();
140
+ reader.onload = (e) => {
141
+ this.previewTarget.src = e.target.result;
142
+ this.promptTarget.classList.add('hidden');
143
+ this.previewContainerTarget.classList.remove('hidden');
144
+ };
145
+ reader.readAsDataURL(file);
146
+ }
147
+ <% else %>
148
+ // Show status
149
+ if (files.length === 1) {
150
+ this.statusTextTarget.textContent = `Selected: ${files[0].name}`;
151
+ } else {
152
+ this.statusTextTarget.textContent = `Selected ${files.length} files`;
153
+ }
154
+ this.promptTarget.classList.add('hidden');
155
+ this.statusTarget.classList.remove('hidden');
156
+ <% end %>
157
+ }
158
+
159
+ clearFile() {
160
+ this.inputTarget.value = '';
161
+ <% if preview %>
162
+ this.promptTarget.classList.remove('hidden');
163
+ this.previewContainerTarget.classList.add('hidden');
164
+ this.previewTarget.src = '';
165
+ <% else %>
166
+ this.promptTarget.classList.remove('hidden');
167
+ this.statusTarget.classList.add('hidden');
168
+ <% end %>
169
+ this.hideError();
170
+ }
171
+
172
+ showError(message) {
173
+ this.errorTarget.textContent = message;
174
+ this.errorTarget.classList.remove('hidden');
175
+ }
176
+
177
+ hideError() {
178
+ this.errorTarget.classList.add('hidden');
179
+ this.errorTarget.textContent = '';
180
+ }
181
+ });
182
+ }
183
+ }
184
+ </script>
@@ -0,0 +1,23 @@
1
+ <%
2
+ # Use configuration from the gem if available, otherwise fall back to defaults
3
+ footer_config = NeonSakura.config
4
+ app_name = footer_config.app_name
5
+ %>
6
+
7
+ <footer class="footer-container" role="contentinfo" aria-label="Site footer">
8
+ <div class="footer-content">
9
+ <p class="footer-text">
10
+ <%= app_name %>, Copyright <%= Time.current.year %>.
11
+ <% if defined?(user_signed_in?) && user_signed_in? %>
12
+ <% if footer_config.respond_to?(:version_module) && footer_config.version_module %>
13
+ <span class="footer-version">Build Version: <%= footer_config.version_module.commit_hash %></span>
14
+ <% end %>
15
+ <% end %>
16
+ <div class="col-md-6 text-md-end">
17
+ <small class="text-muted">
18
+ Built with <span class="text-red-500">♥</span> using Rails
19
+ </small>
20
+ </div>
21
+ </p>
22
+ </div>
23
+ </footer>
@@ -0,0 +1,42 @@
1
+ <%
2
+ # Use configuration from the gem if available, otherwise fall back to defaults
3
+ header_config = NeonSakura.config
4
+ show_header = header_config.show_header
5
+ app_name = header_config.app_name
6
+ show_version = header_config.show_version
7
+ nav_links = header_config.nav_links
8
+ %>
9
+
10
+ <% if show_header != false %>
11
+ <header class="header-container" role="banner" aria-label="Site header">
12
+ <div class="header-content">
13
+ <div class="header-main">
14
+ <h1 class="header-title">
15
+ <%= app_name %>
16
+ </h1>
17
+
18
+ <% if nav_links && nav_links.any? %>
19
+ <nav class="header-navigation" role="navigation">
20
+ <ul class="nav-list">
21
+ <% nav_links.each do |link| %>
22
+ <li class="nav-item">
23
+ <a href="<%= link[:path] %>" class="nav-link">
24
+ <%= link[:name] %>
25
+ </a>
26
+ </li>
27
+ <% end %>
28
+ </ul>
29
+ </nav>
30
+ <% end %>
31
+ </div>
32
+
33
+ <% if show_version %>
34
+ <% if header_config.respond_to?(:version_module) && header_config.version_module %>
35
+ <div class="header-version">
36
+ Version <%= header_config.version_module.commit_hash %>
37
+ </div>
38
+ <% end %>
39
+ <% end %>
40
+ </div>
41
+ </header>
42
+ <% end %>