nitro_kit 0.9.0 → 2.0.0.alpha.2

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 (219) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/plugins/marketplace.json +20 -0
  3. data/CHANGELOG.md +172 -0
  4. data/LICENSE +28 -0
  5. data/README.md +22 -15
  6. data/STYLE_GUIDE.md +425 -0
  7. data/app/assets/stylesheets/nitro_kit-tailwind-v4.css +38 -0
  8. data/app/assets/stylesheets/nitro_kit.css +7239 -0
  9. data/app/components/nitro_kit/accordion.rb +125 -65
  10. data/app/components/nitro_kit/alert.rb +80 -43
  11. data/app/components/nitro_kit/app_navigation.rb +307 -0
  12. data/app/components/nitro_kit/app_shell.rb +227 -0
  13. data/app/components/nitro_kit/appearance_bootstrap.rb +146 -0
  14. data/app/components/nitro_kit/appearance_picker.rb +156 -0
  15. data/app/components/nitro_kit/auth_shell.rb +34 -0
  16. data/app/components/nitro_kit/avatar.rb +85 -29
  17. data/app/components/nitro_kit/avatar_stack.rb +155 -9
  18. data/app/components/nitro_kit/badge.rb +43 -81
  19. data/app/components/nitro_kit/button.rb +180 -103
  20. data/app/components/nitro_kit/button_group.rb +63 -11
  21. data/app/components/nitro_kit/button_to.rb +69 -0
  22. data/app/components/nitro_kit/card.rb +42 -45
  23. data/app/components/nitro_kit/checkbox.rb +115 -62
  24. data/app/components/nitro_kit/checkbox_group.rb +131 -22
  25. data/app/components/nitro_kit/choice.rb +55 -0
  26. data/app/components/nitro_kit/combobox.rb +292 -105
  27. data/app/components/nitro_kit/command_palette.rb +412 -0
  28. data/app/components/nitro_kit/component.rb +322 -46
  29. data/app/components/nitro_kit/container.rb +32 -0
  30. data/app/components/nitro_kit/control_group.rb +45 -0
  31. data/app/components/nitro_kit/danger_zone.rb +78 -0
  32. data/app/components/nitro_kit/data_section.rb +106 -0
  33. data/app/components/nitro_kit/details_table.rb +229 -0
  34. data/app/components/nitro_kit/dialog.rb +257 -83
  35. data/app/components/nitro_kit/dropdown.rb +335 -122
  36. data/app/components/nitro_kit/dropzone.rb +326 -0
  37. data/app/components/nitro_kit/empty_state.rb +99 -0
  38. data/app/components/nitro_kit/field.rb +461 -236
  39. data/app/components/nitro_kit/field_group.rb +15 -8
  40. data/app/components/nitro_kit/fieldset.rb +37 -39
  41. data/app/components/nitro_kit/flex.rb +49 -0
  42. data/app/components/nitro_kit/form_builder.rb +313 -72
  43. data/app/components/nitro_kit/form_section.rb +79 -0
  44. data/app/components/nitro_kit/grid.rb +34 -0
  45. data/app/components/nitro_kit/icon.rb +54 -25
  46. data/app/components/nitro_kit/input.rb +97 -10
  47. data/app/components/nitro_kit/label.rb +22 -6
  48. data/app/components/nitro_kit/layout_options.rb +7 -0
  49. data/app/components/nitro_kit/page_header.rb +76 -0
  50. data/app/components/nitro_kit/pagination.rb +355 -69
  51. data/app/components/nitro_kit/pagination_bar.rb +90 -0
  52. data/app/components/nitro_kit/progressive_image.rb +181 -0
  53. data/app/components/nitro_kit/radio_button.rb +77 -55
  54. data/app/components/nitro_kit/radio_button_group.rb +107 -36
  55. data/app/components/nitro_kit/responsive_value.rb +94 -0
  56. data/app/components/nitro_kit/rich_text_area.rb +31 -0
  57. data/app/components/nitro_kit/select.rb +129 -64
  58. data/app/components/nitro_kit/settings_layout.rb +152 -0
  59. data/app/components/nitro_kit/sheet.rb +202 -0
  60. data/app/components/nitro_kit/stat_grid.rb +75 -0
  61. data/app/components/nitro_kit/switch.rb +108 -51
  62. data/app/components/nitro_kit/table.rb +237 -45
  63. data/app/components/nitro_kit/tabs.rb +168 -63
  64. data/app/components/nitro_kit/textarea.rb +81 -10
  65. data/app/components/nitro_kit/toast.rb +211 -80
  66. data/app/components/nitro_kit/toolbar.rb +75 -0
  67. data/app/components/nitro_kit/tooltip.rb +235 -34
  68. data/app/components/nitro_kit/typeset.rb +28 -0
  69. data/app/javascript/controllers/nk/app_shell_controller.js +224 -0
  70. data/app/javascript/controllers/nk/appearance_controller.js +73 -0
  71. data/app/javascript/controllers/nk/avatar_controller.js +15 -0
  72. data/app/javascript/controllers/nk/button_controller.js +56 -0
  73. data/app/javascript/controllers/nk/checkable_controller.js +41 -0
  74. data/app/javascript/controllers/nk/combobox_controller.js +314 -82
  75. data/app/javascript/controllers/nk/command_palette_controller.js +249 -0
  76. data/app/javascript/controllers/nk/dialog_controller.js +14 -10
  77. data/app/javascript/controllers/nk/dropdown_controller.js +106 -58
  78. data/app/javascript/controllers/nk/dropzone/direct_upload.js +61 -0
  79. data/app/javascript/controllers/nk/dropzone/file_rules.js +77 -0
  80. data/app/javascript/controllers/nk/dropzone/form_submit_lock.js +30 -0
  81. data/app/javascript/controllers/nk/dropzone_controller.js +482 -0
  82. data/app/javascript/controllers/nk/overlay_position.js +37 -0
  83. data/app/javascript/controllers/nk/progressive_image_controller.js +116 -0
  84. data/app/javascript/controllers/nk/tabs_controller.js +135 -23
  85. data/app/javascript/controllers/nk/toast_controller.js +112 -45
  86. data/app/javascript/controllers/nk/tooltip_controller.js +12 -47
  87. data/config/importmap.rb +2 -0
  88. data/config/locales/en.yml +98 -0
  89. data/docs/agent_guide.md +139 -0
  90. data/docs/agent_native_spec.md +422 -0
  91. data/docs/component_contracts.md +263 -0
  92. data/docs/customization.md +595 -0
  93. data/docs/hotwire.md +94 -0
  94. data/docs/initialization_prompt.md +36 -0
  95. data/docs/migration_1_to_2.md +318 -0
  96. data/docs/new_app_strategy.md +22 -0
  97. data/docs/patterns/application_foundation.md +136 -0
  98. data/docs/patterns/crud_resource.md +144 -0
  99. data/docs/patterns/destructive_action.md +105 -0
  100. data/docs/patterns/flash_and_toast.md +57 -0
  101. data/docs/patterns/inline_edit.md +59 -0
  102. data/docs/patterns/queryable_collection.md +187 -0
  103. data/docs/patterns/resource_form.md +126 -0
  104. data/docs/rails_conventions.md +95 -0
  105. data/docs/rails_integration.md +514 -0
  106. data/lib/generators/nitro_kit/install_generator.rb +48 -0
  107. data/lib/generators/nitro_kit/upgrade_smoke_tests_generator.rb +67 -0
  108. data/lib/nitro_kit/engine.rb +20 -0
  109. data/lib/nitro_kit/installation.rb +671 -0
  110. data/lib/nitro_kit/migration_inventory.rb +345 -0
  111. data/lib/nitro_kit/upgrade_smoke_test.rb +403 -0
  112. data/lib/nitro_kit/version.rb +1 -1
  113. data/lib/nitro_kit.rb +5 -42
  114. data/lib/rails/commands/nitro_kit/nitro_kit_command.rb +42 -0
  115. data/lib/tasks/nitro_kit_tasks.rake +79 -4
  116. data/plugins/nitro-kit/.codex-plugin/plugin.json +28 -0
  117. data/plugins/nitro-kit/skills/nitro-kit-hotwire/SKILL.md +67 -0
  118. data/plugins/nitro-kit/skills/nitro-kit-hotwire/agents/openai.yaml +4 -0
  119. data/plugins/nitro-kit/skills/nitro-kit-rails/SKILL.md +60 -0
  120. data/plugins/nitro-kit/skills/nitro-kit-rails/agents/openai.yaml +4 -0
  121. data/plugins/nitro-kit/skills/nitro-kit-ui/SKILL.md +62 -0
  122. data/plugins/nitro-kit/skills/nitro-kit-ui/agents/openai.yaml +4 -0
  123. data/src/stylesheets/nitro_kit/components/accordion.css +119 -0
  124. data/src/stylesheets/nitro_kit/components/alert.css +82 -0
  125. data/src/stylesheets/nitro_kit/components/app_navigation.css +156 -0
  126. data/src/stylesheets/nitro_kit/components/app_shell.css +605 -0
  127. data/src/stylesheets/nitro_kit/components/appearance_picker.css +117 -0
  128. data/src/stylesheets/nitro_kit/components/auth_shell.css +8 -0
  129. data/src/stylesheets/nitro_kit/components/avatar.css +81 -0
  130. data/src/stylesheets/nitro_kit/components/avatar_stack.css +80 -0
  131. data/src/stylesheets/nitro_kit/components/badge.css +76 -0
  132. data/src/stylesheets/nitro_kit/components/button.css +283 -0
  133. data/src/stylesheets/nitro_kit/components/button_group.css +53 -0
  134. data/src/stylesheets/nitro_kit/components/button_to.css +6 -0
  135. data/src/stylesheets/nitro_kit/components/card.css +70 -0
  136. data/src/stylesheets/nitro_kit/components/checkbox.css +160 -0
  137. data/src/stylesheets/nitro_kit/components/checkbox_group.css +61 -0
  138. data/src/stylesheets/nitro_kit/components/combobox.css +148 -0
  139. data/src/stylesheets/nitro_kit/components/command_palette.css +255 -0
  140. data/src/stylesheets/nitro_kit/components/container.css +25 -0
  141. data/src/stylesheets/nitro_kit/components/control_group.css +149 -0
  142. data/src/stylesheets/nitro_kit/components/danger_zone.css +54 -0
  143. data/src/stylesheets/nitro_kit/components/data_section.css +40 -0
  144. data/src/stylesheets/nitro_kit/components/details_table.css +53 -0
  145. data/src/stylesheets/nitro_kit/components/dialog.css +80 -0
  146. data/src/stylesheets/nitro_kit/components/dropdown.css +152 -0
  147. data/src/stylesheets/nitro_kit/components/dropzone.css +198 -0
  148. data/src/stylesheets/nitro_kit/components/empty_state.css +53 -0
  149. data/src/stylesheets/nitro_kit/components/field.css +79 -0
  150. data/src/stylesheets/nitro_kit/components/field_group.css +6 -0
  151. data/src/stylesheets/nitro_kit/components/fieldset.css +28 -0
  152. data/src/stylesheets/nitro_kit/components/flex.css +455 -0
  153. data/src/stylesheets/nitro_kit/components/form_section.css +71 -0
  154. data/src/stylesheets/nitro_kit/components/grid.css +308 -0
  155. data/src/stylesheets/nitro_kit/components/icon.css +26 -0
  156. data/src/stylesheets/nitro_kit/components/input.css +80 -0
  157. data/src/stylesheets/nitro_kit/components/label.css +9 -0
  158. data/src/stylesheets/nitro_kit/components/layout.css +440 -0
  159. data/src/stylesheets/nitro_kit/components/page_header.css +61 -0
  160. data/src/stylesheets/nitro_kit/components/pagination.css +119 -0
  161. data/src/stylesheets/nitro_kit/components/pagination_bar.css +41 -0
  162. data/src/stylesheets/nitro_kit/components/palette.css +113 -0
  163. data/src/stylesheets/nitro_kit/components/progressive_image.css +139 -0
  164. data/src/stylesheets/nitro_kit/components/radio_button.css +127 -0
  165. data/src/stylesheets/nitro_kit/components/radio_button_group.css +143 -0
  166. data/src/stylesheets/nitro_kit/components/rich_text_area.css +21 -0
  167. data/src/stylesheets/nitro_kit/components/select.css +70 -0
  168. data/src/stylesheets/nitro_kit/components/settings_layout.css +100 -0
  169. data/src/stylesheets/nitro_kit/components/sheet.css +100 -0
  170. data/src/stylesheets/nitro_kit/components/stat_grid.css +51 -0
  171. data/src/stylesheets/nitro_kit/components/switch.css +152 -0
  172. data/src/stylesheets/nitro_kit/components/table.css +116 -0
  173. data/src/stylesheets/nitro_kit/components/tabs.css +138 -0
  174. data/src/stylesheets/nitro_kit/components/textarea.css +39 -0
  175. data/src/stylesheets/nitro_kit/components/toast.css +131 -0
  176. data/src/stylesheets/nitro_kit/components/toolbar.css +78 -0
  177. data/src/stylesheets/nitro_kit/components/tooltip.css +124 -0
  178. data/src/stylesheets/nitro_kit/components/typeset.css +191 -0
  179. data/src/stylesheets/nitro_kit/layers.css +7 -0
  180. data/src/stylesheets/nitro_kit/reset.css +192 -0
  181. data/src/stylesheets/nitro_kit/tokens.css +198 -0
  182. metadata +182 -51
  183. data/Rakefile +0 -8
  184. data/app/assets/tailwind/application.css +0 -49
  185. data/app/components/nitro_kit/datepicker.rb +0 -9
  186. data/app/helpers/nitro_kit/accordion_helper.rb +0 -9
  187. data/app/helpers/nitro_kit/alert_helper.rb +0 -11
  188. data/app/helpers/nitro_kit/avatar_helper.rb +0 -13
  189. data/app/helpers/nitro_kit/badge_helper.rb +0 -13
  190. data/app/helpers/nitro_kit/button_group_helper.rb +0 -9
  191. data/app/helpers/nitro_kit/button_helper.rb +0 -49
  192. data/app/helpers/nitro_kit/card_helper.rb +0 -9
  193. data/app/helpers/nitro_kit/checkbox_helper.rb +0 -50
  194. data/app/helpers/nitro_kit/combobox_helper.rb +0 -9
  195. data/app/helpers/nitro_kit/datepicker_helper.rb +0 -9
  196. data/app/helpers/nitro_kit/dialog_helper.rb +0 -9
  197. data/app/helpers/nitro_kit/dropdown_helper.rb +0 -9
  198. data/app/helpers/nitro_kit/field_group_helper.rb +0 -9
  199. data/app/helpers/nitro_kit/field_helper.rb +0 -9
  200. data/app/helpers/nitro_kit/fieldset_helper.rb +0 -9
  201. data/app/helpers/nitro_kit/form_helper.rb +0 -13
  202. data/app/helpers/nitro_kit/icon_helper.rb +0 -9
  203. data/app/helpers/nitro_kit/input_helper.rb +0 -37
  204. data/app/helpers/nitro_kit/label_helper.rb +0 -19
  205. data/app/helpers/nitro_kit/pagination_helper.rb +0 -46
  206. data/app/helpers/nitro_kit/radio_button_helper.rb +0 -23
  207. data/app/helpers/nitro_kit/select_helper.rb +0 -24
  208. data/app/helpers/nitro_kit/switch_helper.rb +0 -9
  209. data/app/helpers/nitro_kit/table_helper.rb +0 -9
  210. data/app/helpers/nitro_kit/tabs_helper.rb +0 -9
  211. data/app/helpers/nitro_kit/textarea_helper.rb +0 -9
  212. data/app/helpers/nitro_kit/toast_helper.rb +0 -25
  213. data/app/helpers/nitro_kit/tooltip_helper.rb +0 -9
  214. data/app/javascript/controllers/nk/accordion_controller.js +0 -18
  215. data/app/javascript/controllers/nk/datepicker_controller.js +0 -5
  216. data/app/javascript/controllers/nk/switch_controller.js +0 -32
  217. data/lib/generators/nitro_kit/component_generator.rb +0 -84
  218. data/lib/nitro_kit/schema_builder.rb +0 -129
  219. data/lib/nitro_kit/variants.rb +0 -21
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NitroKit
4
+ class AppShell < Component
5
+ alias_method :html_main, :main
6
+
7
+ LAYOUTS = %i[sidebar topbar hybrid].freeze
8
+ REGIONS = %i[brand navigation topbar main].freeze
9
+ REQUIRED_REGIONS = %i[navigation main].freeze
10
+ private_constant :REQUIRED_REGIONS
11
+
12
+ def initialize(
13
+ id:,
14
+ layout: :sidebar,
15
+ skip_link_label: I18n.t("nitro_kit.app_shell.skip_link"),
16
+ open_navigation_label: I18n.t("nitro_kit.app_shell.open_navigation"),
17
+ close_navigation_label: I18n.t("nitro_kit.app_shell.close_navigation"),
18
+ navigation_dialog_label: I18n.t("nitro_kit.app_shell.navigation_dialog"),
19
+ html: {},
20
+ aria: {},
21
+ data: {},
22
+ desperately_need_a_class: nil
23
+ )
24
+ @identifier = component_id(id)
25
+ @layout = validate_choice!(:layout, layout, LAYOUTS)
26
+ @skip_link_label = validate_label!(:skip_link_label, skip_link_label)
27
+ @open_navigation_label = validate_label!(:open_navigation_label, open_navigation_label)
28
+ @close_navigation_label = validate_label!(:close_navigation_label, close_navigation_label)
29
+ @navigation_dialog_label = validate_label!(:navigation_dialog_label, navigation_dialog_label)
30
+ @regions = REGIONS.to_h { |name| [ name, nil ] }
31
+ @collecting = false
32
+
33
+ super(
34
+ component: :app_shell,
35
+ attributes: {
36
+ id: @identifier,
37
+ data: {
38
+ controller: "nk--app-shell",
39
+ layout: @layout,
40
+ state: "closed",
41
+ action: "turbo:before-visit@document->nk--app-shell#closeForNavigation " \
42
+ "turbo:morph@document->nk--app-shell#syncViewport",
43
+ nk__app_shell_open_label_value: @open_navigation_label,
44
+ nk__app_shell_close_label_value: @close_navigation_label
45
+ }
46
+ },
47
+ html:,
48
+ aria:,
49
+ data:,
50
+ desperately_need_a_class:
51
+ )
52
+ end
53
+
54
+ attr_reader :identifier, :layout
55
+
56
+ def view_template(&declarations)
57
+ collect_regions(&declarations)
58
+
59
+ div(**root_attributes) do
60
+ render_skip_link
61
+ render_header
62
+ render_sidebar
63
+ render_dialog
64
+ render_main
65
+ end
66
+ end
67
+
68
+ def brand(&content)
69
+ add_region(:brand, content:)
70
+ end
71
+
72
+ def navigation(&content)
73
+ add_region(:navigation, content:)
74
+ end
75
+
76
+ def topbar(&content)
77
+ add_region(:topbar, content:)
78
+ end
79
+
80
+ def main(&content)
81
+ add_region(:main, content:)
82
+ end
83
+
84
+ private
85
+
86
+ def collect_regions
87
+ raise ArgumentError, "AppShell requires a declaration block" unless block_given?
88
+
89
+ @collecting = true
90
+ output = capture(self) { |shell| yield shell }
91
+ unless output.empty?
92
+ raise ArgumentError, "AppShell render block accepts region declarations, not rendered content"
93
+ end
94
+ REQUIRED_REGIONS.each do |region|
95
+ next if @regions.fetch(region)
96
+
97
+ raise ArgumentError, "AppShell requires exactly one #{region} region"
98
+ end
99
+ ensure
100
+ @collecting = false
101
+ end
102
+
103
+ def add_region(name, content:)
104
+ unless @collecting
105
+ raise ArgumentError, "AppShell #{name} must be declared directly inside the render block"
106
+ end
107
+ if @regions.fetch(name)
108
+ limit = REQUIRED_REGIONS.include?(name) ? "exactly one" : "at most one"
109
+ raise ArgumentError, "AppShell accepts #{limit} #{name} region"
110
+ end
111
+ raise ArgumentError, "AppShell #{name} requires a block" unless content
112
+
113
+ @regions[name] = content
114
+ nil
115
+ end
116
+
117
+ def region(name)
118
+ @regions.fetch(name)
119
+ end
120
+
121
+ def render_skip_link
122
+ a(**slot_attributes(:skip_link, attributes: { href: "##{main_id}" })) { @skip_link_label }
123
+ end
124
+
125
+ def render_header
126
+ header(**slot_attributes(:header)) do
127
+ div(**slot_attributes(:brand)) { render(region(:brand)) } if region(:brand)
128
+ render_mobile_trigger
129
+ div(**slot_attributes(:topbar)) { render(region(:topbar)) } if region(:topbar)
130
+ end
131
+ end
132
+
133
+ def render_mobile_trigger
134
+ button(
135
+ **slot_attributes(
136
+ :mobile_trigger,
137
+ attributes: {
138
+ type: "button",
139
+ aria: {
140
+ controls: drawer_id,
141
+ expanded: false,
142
+ label: @open_navigation_label
143
+ },
144
+ data: {
145
+ nk__app_shell_target: "trigger",
146
+ action: "click->nk--app-shell#toggle"
147
+ }
148
+ }
149
+ )
150
+ ) { render(Icon.new(:menu, size: :sm)) }
151
+ end
152
+
153
+ def render_sidebar
154
+ div(
155
+ **slot_attributes(
156
+ :sidebar,
157
+ attributes: {
158
+ data: { nk__app_shell_target: "sidebar" }
159
+ }
160
+ )
161
+ ) do
162
+ div(
163
+ **slot_attributes(
164
+ :navigation,
165
+ attributes: { data: { nk__app_shell_target: "navigation" } }
166
+ )
167
+ ) { render(region(:navigation)) }
168
+ end
169
+ end
170
+
171
+ def render_dialog
172
+ dialog(
173
+ **slot_attributes(
174
+ :dialog,
175
+ attributes: {
176
+ id: drawer_id,
177
+ aria: { label: @navigation_dialog_label },
178
+ data: {
179
+ nk__app_shell_target: "dialog",
180
+ action: [
181
+ "close->nk--app-shell#dialogClosed",
182
+ "click->nk--app-shell#closeFromBackdrop"
183
+ ].join(" ")
184
+ }
185
+ }
186
+ )
187
+ ) { render_mobile_close }
188
+ end
189
+
190
+ def render_mobile_close
191
+ button(
192
+ **slot_attributes(
193
+ :mobile_close,
194
+ attributes: {
195
+ type: "button",
196
+ aria: { label: @close_navigation_label },
197
+ data: { action: "click->nk--app-shell#close" }
198
+ }
199
+ )
200
+ ) { render(Icon.new(:x, size: :sm)) }
201
+ end
202
+
203
+ def render_main
204
+ html_main(**slot_attributes(:main, attributes: { id: main_id, tabindex: -1 })) { render(region(:main)) }
205
+ end
206
+
207
+ def drawer_id
208
+ "#{identifier}-navigation-drawer"
209
+ end
210
+
211
+ def main_id
212
+ "#{identifier}-main"
213
+ end
214
+
215
+ def component_id(value)
216
+ return value if value.is_a?(String) && value.match?(/\A[A-Za-z0-9][A-Za-z0-9_-]*\z/)
217
+
218
+ raise ArgumentError, "AppShell id must be a fragment-safe String using letters, numbers, underscores, or hyphens"
219
+ end
220
+
221
+ def validate_label!(name, value)
222
+ return value if value.is_a?(String) && !value.strip.empty?
223
+
224
+ raise ArgumentError, "AppShell #{name} must be a non-blank String"
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NitroKit
4
+ class AppearanceBootstrap < Phlex::HTML
5
+ PREFERENCES = %i[light dark system].freeze
6
+ STORAGE_KEY = "nitro-kit-appearance"
7
+
8
+ SCRIPT = <<~JAVASCRIPT.freeze
9
+ (() => {
10
+ const preferences = ["light", "dark", "system"]
11
+ const storageKey = "nitro-kit-appearance"
12
+ const requestEvent = "nitro-kit:appearance-request"
13
+ const changeEvent = "nitro-kit:appearance-change"
14
+ const runtimeKey = Symbol.for("nitro-kit.appearance")
15
+ const suppliedDefault = document.currentScript?.dataset.nkAppearanceDefault
16
+ const valid = (value) => preferences.includes(value)
17
+ const initialDefault = valid(suppliedDefault) ? suppliedDefault : "system"
18
+ const existingRuntime = window[runtimeKey]
19
+
20
+ if (existingRuntime) {
21
+ existingRuntime.start(initialDefault)
22
+ return
23
+ }
24
+
25
+ const root = document.documentElement
26
+ const system = window.matchMedia("(prefers-color-scheme: dark)")
27
+ let defaultPreference = initialDefault
28
+ let preference = initialDefault
29
+ let mediaListening = false
30
+ let started = false
31
+
32
+ const read = () => {
33
+ try {
34
+ const stored = window.localStorage.getItem(storageKey)
35
+ return valid(stored) ? stored : defaultPreference
36
+ } catch (_error) {
37
+ return defaultPreference
38
+ }
39
+ }
40
+
41
+ const write = (value) => {
42
+ try {
43
+ window.localStorage.setItem(storageKey, value)
44
+ } catch (_error) {
45
+ // The in-document preference still works when storage is unavailable.
46
+ }
47
+ }
48
+
49
+ const resolve = () => {
50
+ if (preference !== "system") return preference
51
+ return system.matches ? "dark" : "light"
52
+ }
53
+
54
+ const broadcast = (theme) => {
55
+ window.dispatchEvent(new CustomEvent(changeEvent, {
56
+ detail: { preference, theme }
57
+ }))
58
+ }
59
+
60
+ const reflect = () => {
61
+ const theme = resolve()
62
+ const changed = root.dataset.themePreference !== preference || root.dataset.theme !== theme
63
+
64
+ root.dataset.themePreference = preference
65
+ root.dataset.theme = theme
66
+ if (changed) broadcast(theme)
67
+ }
68
+
69
+ const handleMediaChange = () => reflect()
70
+
71
+ const synchronizeMediaListener = () => {
72
+ if (preference === "system" && !mediaListening) {
73
+ system.addEventListener("change", handleMediaChange)
74
+ mediaListening = true
75
+ } else if (preference !== "system" && mediaListening) {
76
+ system.removeEventListener("change", handleMediaChange)
77
+ mediaListening = false
78
+ }
79
+ }
80
+
81
+ const apply = (nextPreference, persist = false) => {
82
+ if (!valid(nextPreference)) return
83
+
84
+ preference = nextPreference
85
+ synchronizeMediaListener()
86
+ if (persist) write(preference)
87
+ reflect()
88
+ }
89
+
90
+ const handleRequest = (event) => {
91
+ apply(event.detail?.preference, true)
92
+ }
93
+
94
+ const handleStorage = (event) => {
95
+ if (event.key !== storageKey) return
96
+ apply(valid(event.newValue) ? event.newValue : defaultPreference)
97
+ }
98
+
99
+ const runtime = {
100
+ start(nextDefault) {
101
+ if (valid(nextDefault)) defaultPreference = nextDefault
102
+
103
+ if (!started) {
104
+ window.addEventListener(requestEvent, handleRequest)
105
+ window.addEventListener("storage", handleStorage)
106
+ started = true
107
+ }
108
+
109
+ apply(read())
110
+ }
111
+ }
112
+
113
+ window[runtimeKey] = runtime
114
+ runtime.start(initialDefault)
115
+ })()
116
+ JAVASCRIPT
117
+
118
+ CSP_HASH = "sha256-Vcime4euWSeYtHSfjYjqz/XhRyzMcLpn6Ip2LlaHleY="
119
+
120
+ def initialize(default: :system, nonce: nil)
121
+ @default = validate_default!(default)
122
+ @nonce = validate_nonce!(nonce)
123
+ end
124
+
125
+ def view_template
126
+ script(
127
+ nonce: @nonce,
128
+ data: { nk_appearance_default: @default }
129
+ ) { raw safe(SCRIPT) }
130
+ end
131
+
132
+ private
133
+
134
+ def validate_default!(value)
135
+ return value if PREFERENCES.include?(value)
136
+
137
+ raise ArgumentError, "Unknown appearance default #{value.inspect}; expected one of: :light, :dark, :system"
138
+ end
139
+
140
+ def validate_nonce!(value)
141
+ return value if value.nil? || (value.is_a?(String) && !value.strip.empty?)
142
+
143
+ raise ArgumentError, "AppearanceBootstrap nonce must be a non-blank String or nil"
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NitroKit
4
+ class AppearancePicker < Component
5
+ PRESENTATIONS = %i[segmented radios select dropdown].freeze
6
+ PREFERENCES = %i[light dark system].freeze
7
+ ICONS = {
8
+ light: :sun,
9
+ dark: :moon,
10
+ system: :monitor
11
+ }.freeze
12
+
13
+ def initialize(
14
+ id:,
15
+ label: I18n.t("nitro_kit.appearance_picker.label"),
16
+ presentation: :segmented,
17
+ preference: :system,
18
+ html: {},
19
+ aria: {},
20
+ data: {},
21
+ desperately_need_a_class: nil
22
+ )
23
+ @identifier = component_id(id)
24
+ @label = validate_label!(label)
25
+ @presentation = validate_choice!(:presentation, presentation, PRESENTATIONS)
26
+ @preference = validate_choice!(:preference, preference, PREFERENCES)
27
+
28
+ super(
29
+ component: :appearance_picker,
30
+ attributes: {
31
+ id: @identifier,
32
+ data: {
33
+ controller: "nk--appearance",
34
+ presentation: @presentation,
35
+ state: @preference,
36
+ action: "change->nk--appearance#select nitro-kit:appearance-change@window->nk--appearance#synchronize"
37
+ }
38
+ },
39
+ html:,
40
+ aria:,
41
+ data:,
42
+ desperately_need_a_class:
43
+ )
44
+ end
45
+
46
+ attr_reader :identifier, :preference
47
+
48
+ def view_template
49
+ return render_select if @presentation == :select
50
+ return render_dropdown if @presentation == :dropdown
51
+
52
+ fieldset(**root_attributes) do
53
+ legend(**slot_attributes(:legend)) { plain(@label) }
54
+ div(**slot_attributes(:options)) do
55
+ PREFERENCES.each { |preference| render_option(preference) }
56
+ end
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def preference_label(preference)
63
+ I18n.t("nitro_kit.appearance_picker.preferences.#{preference}")
64
+ end
65
+
66
+ def render_dropdown
67
+ div(**root_attributes) do
68
+ render Dropdown.new(id: "#{identifier}-dropdown", placement: :bottom_end) do |menu|
69
+ menu.trigger(
70
+ variant: :ghost,
71
+ icon: ICONS.fetch(@preference),
72
+ label: @label,
73
+ data: { nk__appearance_target: "trigger" }
74
+ )
75
+ menu.title(@label)
76
+ PREFERENCES.each do |option|
77
+ menu.item(
78
+ preference_label(option),
79
+ icon: ICONS.fetch(option),
80
+ data: {
81
+ appearance_preference: option,
82
+ nk__appearance_target: "input",
83
+ action: "click->nk--appearance#select"
84
+ }
85
+ )
86
+ end
87
+ end
88
+ end
89
+ end
90
+
91
+ def render_select
92
+ label(**root_attributes) do
93
+ span(**slot_attributes(:legend)) { plain(@label) }
94
+ select(
95
+ **slot_attributes(
96
+ :select,
97
+ data: {
98
+ nk__appearance_target: "input",
99
+ action: "change->nk--appearance#select"
100
+ }
101
+ )
102
+ ) do
103
+ PREFERENCES.each do |value|
104
+ option(value:, selected: value == @preference) do
105
+ plain(preference_label(value))
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
111
+
112
+ def render_option(preference)
113
+ label(
114
+ **slot_attributes(
115
+ :option,
116
+ attributes: { for: option_id(preference) }
117
+ )
118
+ ) do
119
+ input(
120
+ **slot_attributes(
121
+ :control,
122
+ attributes: {
123
+ id: option_id(preference),
124
+ type: "radio",
125
+ name: "#{identifier}-preference",
126
+ value: preference,
127
+ checked: preference == @preference,
128
+ data: { nk__appearance_target: "input" }
129
+ }
130
+ )
131
+ )
132
+ span(**slot_attributes(:label)) { plain(preference_label(preference)) }
133
+ end
134
+ end
135
+
136
+ def option_id(preference)
137
+ "#{identifier}-#{preference}"
138
+ end
139
+
140
+ # Private copy of the AppShell identifier contract. Extract a shared
141
+ # validator into Component once its owner lands.
142
+ def component_id(value)
143
+ return value if value.is_a?(String) && value.match?(/\A[A-Za-z0-9][A-Za-z0-9_-]*\z/)
144
+
145
+ raise ArgumentError,
146
+ "AppearancePicker id must be a String starting with a letter or digit and containing " \
147
+ "only letters, digits, hyphens, and underscores"
148
+ end
149
+
150
+ def validate_label!(value)
151
+ return value if value.is_a?(String) && value.present?
152
+
153
+ raise ArgumentError, "AppearancePicker label must be a non-blank String"
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NitroKit
4
+ class AuthShell < Component
5
+ def initialize(
6
+ id: nil,
7
+ html: {},
8
+ aria: {},
9
+ data: {},
10
+ desperately_need_a_class: nil
11
+ )
12
+ super(
13
+ component: :auth_shell,
14
+ attributes: { id: }.compact,
15
+ html:,
16
+ aria:,
17
+ data:,
18
+ desperately_need_a_class:
19
+ )
20
+ end
21
+
22
+ def view_template
23
+ raise ArgumentError, "NitroKit::AuthShell requires a content block" unless block_given?
24
+
25
+ main(**root_attributes) do
26
+ render NitroKit::Container.new(size: :md) do
27
+ render NitroKit::Flex.new(dir: :col, gap: 6, align: :stretch) do
28
+ yield
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -2,51 +2,107 @@
2
2
 
3
3
  module NitroKit
4
4
  class Avatar < Component
5
- include Phlex::Rails::Helpers::ImageTag
5
+ SIZES = %i[xs sm md lg].freeze
6
6
 
7
- def initialize(src_arg = nil, src: nil, size: :md, **attrs)
8
- @src = src_arg || src
9
- @size = size
7
+ def initialize(
8
+ src: nil,
9
+ alt: "",
10
+ fallback: nil,
11
+ size: :md,
12
+ loading: "lazy",
13
+ decoding: "async",
14
+ id: nil,
15
+ html: {},
16
+ aria: {},
17
+ data: {},
18
+ desperately_need_a_class: nil
19
+ )
20
+ raise ArgumentError, "alt must be a String" unless alt.is_a?(String)
21
+ unless fallback.nil? || fallback.is_a?(String)
22
+ raise ArgumentError, "fallback must be a String or nil"
23
+ end
24
+
25
+ @src = src
26
+ @alt = alt
27
+ @fallback = fallback || initials_for(alt)
28
+ @size = validate_choice!(:size, size, SIZES)
29
+ root_aria = fallback_aria(aria)
10
30
 
11
31
  super(
12
- attrs,
13
- class: [ container_class, size_classes ]
32
+ component: :avatar,
33
+ attributes: {
34
+ id:,
35
+ role: !src? && !alt.empty? ? "img" : nil,
36
+ data: { controller: src? ? "nk--avatar" : nil }.compact
37
+ },
38
+ html:,
39
+ aria: root_aria,
40
+ data:,
41
+ size:,
42
+ desperately_need_a_class:
14
43
  )
44
+
45
+ @image_attributes = {
46
+ alt:,
47
+ loading:,
48
+ decoding:
49
+ }
15
50
  end
16
51
 
17
- attr_reader :src, :size
52
+ attr_reader :src, :alt, :fallback, :size
18
53
 
19
- def view_template(&block)
20
- div(**attrs) do
21
- image
22
- end
23
- end
54
+ def view_template
55
+ span(**root_attributes) do
56
+ span(
57
+ **slot_attributes(
58
+ :fallback,
59
+ attributes: src? ? { data: { nk__avatar_target: "fallback" } } : {},
60
+ aria: { hidden: (src? || !alt.empty?) ? true : nil }
61
+ )
62
+ ) { fallback }
24
63
 
25
- def image
26
- image_tag(src, class: image_class)
64
+ if src?
65
+ img(
66
+ **slot_attributes(
67
+ :image,
68
+ attributes: @image_attributes.merge(
69
+ src:,
70
+ data: {
71
+ nk__avatar_target: "image",
72
+ action: "error->nk--avatar#failed"
73
+ }
74
+ )
75
+ )
76
+ )
77
+ end
78
+ end
27
79
  end
28
80
 
29
81
  private
30
82
 
31
- def size_classes
32
- case size
33
- when :sm
34
- "size-8"
35
- when :md
36
- "size-12"
37
- when :lg
38
- "size-16"
39
- else
40
- raise ArgumentError, "Invalid size: #{size}"
41
- end
83
+ def src?
84
+ !src.nil? && (!src.respond_to?(:empty?) || !src.empty?)
42
85
  end
43
86
 
44
- def container_class
45
- "inline-flex overflow-hidden rounded-full"
87
+ def fallback_aria(aria)
88
+ return aria if src? || alt.empty?
89
+ raise ArgumentError, "aria must be a Hash" unless aria.is_a?(Hash)
90
+
91
+ label_key = aria.keys.find { |key| key.to_s.downcase.tr("_", "-") == "label" }
92
+ label_key ? aria : { label: alt }.merge(aria)
46
93
  end
47
94
 
48
- def image_class
49
- "block size-full bg-muted"
95
+ def initials_for(name)
96
+ words = name.strip.split
97
+ return "?" if words.empty?
98
+
99
+ initials = if words.one?
100
+ words.first[0, 2]
101
+ else
102
+ [ words.first[0], words.last[0] ].join
103
+ end
104
+
105
+ initials.upcase
50
106
  end
51
107
  end
52
108
  end