baldur 0.2.5 → 0.3.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -0
  3. data/TODO.md +27 -7
  4. data/app/assets/javascripts/baldur/controllers/segmented_tabs_controller.js +53 -2
  5. data/app/assets/javascripts/baldur/controllers/theme_controller.js +4 -3
  6. data/app/assets/stylesheets/baldur/application/components/auth-page.css +5 -6
  7. data/app/assets/stylesheets/baldur/application/components/table.css +17 -7
  8. data/app/helpers/baldur/ui_helper.rb +11 -2
  9. data/app/helpers/baldur/ui_helper_feedback.rb +19 -2
  10. data/app/views/baldur/components/_button.html.erb +4 -0
  11. data/app/views/baldur/components/_segmented_buttons.html.erb +14 -7
  12. data/app/views/baldur/components/_snackbar_stack.html.erb +10 -6
  13. data/app/views/baldur/components/_table.html.erb +6 -4
  14. data/app/views/baldur/optional/_auth_page.html.erb +2 -2
  15. data/baldur.gemspec +4 -1
  16. data/context7.json +17 -0
  17. data/docs/alerts-and-snackbars.md +72 -0
  18. data/docs/auth.md +66 -0
  19. data/docs/forms.md +267 -0
  20. data/docs/installation.md +63 -0
  21. data/docs/marketing.md +77 -0
  22. data/docs/modals-and-panels.md +55 -0
  23. data/docs/security.md +11 -0
  24. data/docs/sidebar.md +105 -0
  25. data/docs/styling.md +34 -0
  26. data/docs/tables.md +173 -0
  27. data/docs/tabs-and-segmented-controls.md +509 -0
  28. data/docs/theme.md +118 -0
  29. data/lib/baldur/version.rb +1 -1
  30. data/llms-full.txt +179 -0
  31. data/llms.txt +35 -0
  32. data/test/hidden_field_helper_test.rb +23 -0
  33. data/test/segmented_buttons_helper_test.rb +85 -0
  34. data/test/snackbar_stack_helper_test.rb +121 -0
  35. data/test/table_helper_test.rb +118 -0
  36. data/test/text_field_helper_test.rb +40 -0
  37. data/test/theme_toggle_helper_test.rb +2 -0
  38. metadata +22 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6c30a02aad73679e5b6bb7d00222718c2d41404dc711304bc8c06e93b9263544
4
- data.tar.gz: beaedad46ac4a8d0bf61b11812c0004675e8a9d707dee05fe54ae589f1ef1ff9
3
+ metadata.gz: 173fbb6ff876c4440db1b17b3edb60f7d9c9bee99fdf18d59a733c411897d5b1
4
+ data.tar.gz: 6a51c87730719ddb3155a7eb70d7c6ab6f257ce66b0fb0b23a94582434665beb
5
5
  SHA512:
6
- metadata.gz: 75fe161cdcd9d876121c3ec9182ce70ce402b982a9b42628b2c19bd92286e695df48dc9252f59b11eb0a58406f690d7df50bb74eed56421b425377a6d1f85773
7
- data.tar.gz: dd85ce27768bb4802ee67f2b27e11926dedf286a82d4dea893ac6463c47727a174017580ab62dbc08b7b6a9d1a7b269a82a392bbafc635ae73363fbb78b348e1
6
+ metadata.gz: c02cccdbe76f6e8b19a76dc70fe932b6ccb303a381de63dd9270f1414cd658ec310d5d965f8649c1d44a6fb1f62eeceacc92ab9a0af8a3b33b9ba051836d43b0
7
+ data.tar.gz: fe5a8cbd70bbe930a4f738de6ab52b15ce793ef2c60e1117026d22c6388910c1774e5c879581f28a825651e201b11fc6bb1d5819a8f77355cb120af0ca0ee67b
data/README.md CHANGED
@@ -72,12 +72,22 @@ Render a sidebar with navigation and a main content area:
72
72
  - [Styling](docs/styling.md)
73
73
  - [Sidebar](docs/sidebar.md)
74
74
  - [Auth](docs/auth.md)
75
+ - [Forms](docs/forms.md)
75
76
  - [Modals and Panels](docs/modals-and-panels.md)
76
77
  - [Alerts and Snackbars](docs/alerts-and-snackbars.md)
78
+ - [Tabs and Segmented Controls](docs/tabs-and-segmented-controls.md)
77
79
  - [Tables](docs/tables.md)
78
80
  - [Marketing](docs/marketing.md)
79
81
  - [Security](docs/security.md)
80
82
 
83
+ ## LLM / Context7 Docs
84
+
85
+ - [llms.txt](llms.txt) for agent-friendly doc discovery and page summaries.
86
+ - [llms-full.txt](llms-full.txt) for a denser, one-file integration guide.
87
+ - [context7.json](context7.json) scopes Context7 parsing to Baldur's primary docs and agent rules.
88
+
89
+ These files are intended for Context7 and other doc-ingestion tools that look for machine-readable documentation entrypoints.
90
+
81
91
  ## Security
82
92
 
83
93
  See [docs/security.md](docs/security.md) for artifact verification, MFA requirements, and vulnerability reporting.
data/TODO.md CHANGED
@@ -1,7 +1,5 @@
1
1
  # Baldur TODO
2
2
 
3
- - [ ] Consider `pagy` gem for tables component
4
-
5
3
  ## Install and Verification
6
4
  - [ ] Harden `baldur:install` so host assumptions are reduced
7
5
  - [ ] Audit generated controller shims against all shipped components
@@ -9,11 +7,11 @@
9
7
  - [ ] Add end-to-end install verification in dummy app
10
8
 
11
9
  ## Showcase App and Docs
12
- - [ ] Add agent-friendly docs, fetchable by Context7
13
10
  - [ ] Add a dedicated dummy app in the extracted gem repo for visual smoke checks
14
11
  - [ ] Add a component inventory/showcase page in that dummy app
15
12
  - [ ] Add interaction showcase pages for modal, sidebar, menu select, snackbar, and `panel_secondary`
16
13
  - [ ] Add copy-paste examples for core surfaces from the showcase app back into docs
14
+ - [ ] Add a dummy-app example showing segmented tabs inside a form with hidden tab state
17
15
 
18
16
  ## Starter Templates
19
17
  - [ ] Add dedicated password reset templates
@@ -29,6 +27,31 @@
29
27
  - [ ] Bind labels, hints, errors, and invalid states automatically
30
28
  - [ ] Document model-bound form usage
31
29
 
30
+ ## Buttons
31
+ - [x] `ui_button` should support `formaction` and `formmethod` attributes so host apps can use it as a form-action button (e.g. Turbo-frame-aware submit that posts to a different endpoint)
32
+ - Currently host apps fall back to raw `<button>` or `button_tag` because `ui_button` only forwards `type`, `data`, `aria`, `disabled`, and `form`.
33
+ - Add passthrough for `formaction` and `formmethod` in both `ui_button` helper signature and `baldur/components/button` partial.
34
+ - Accept `value:` and `name:` too so it can double as a named submit button.
35
+ - Verify with a dummy-app example that submits within a Turbo Frame to a custom formaction.
36
+ - Why: keeps host code free of custom HTML button wiring and lets `ui_button` act as a full submit/FAB replacement.
37
+ - [ ] Document interplay between `ui_button`, hidden state fields, and segmented tabs for multi-panel forms
38
+ - avoid host apps inventing ad hoc flow-state patterns just to support review/commit loops
39
+
40
+ ## Tabs and Segmented Controls
41
+ - [x] Add a first-class `ui_tabs` primitive or documented tabs pattern built on `ui_segmented_buttons`
42
+ - selected tab trigger
43
+ - tab panels
44
+ - ARIA wiring
45
+ - keyboard behavior
46
+ - hidden / inactive panel handling
47
+ - [x] Provide a small Stimulus controller for segmented-button tabs so host apps do not hand-roll `show/hide` logic
48
+ - [x] Support syncing selected tab into a hidden input for form-backed workflows
49
+ - [x] Document when tabs should be:
50
+ - instant client-side state
51
+ - Turbo GET navigation
52
+ - preserved across POST / redirect / render flows
53
+ - [ ] Consider adding a higher-level `ui_tabs` helper on top of the documented segmented-buttons pattern once host usage stabilizes
54
+
32
55
  ## Tables and Resource Screens
33
56
  - [ ] Add a higher-level resource index pattern on top of existing table primitives
34
57
  - [ ] Support search, filters, row actions, bulk select, and empty states
@@ -37,13 +60,10 @@
37
60
 
38
61
  ## Theming
39
62
  - [ ] Add a small set of starter theme presets
40
- - [ ] Add first-class `ui_theme_toggle` helper/component so hosts do not need to copy Mimir toggle partial
41
- - [ ] Improve dark-mode/theme controller documentation
42
- - [ ] Theme toggle on auth page templates need top rail
43
- - [ ] Document brand-token customization more clearly
44
63
 
45
64
  ## Accessibility
46
65
  - [ ] Audit keyboard and focus behavior across interactive components
66
+ - [ ] ADA WCAG 2.1 AA compliance
47
67
  - [ ] Add accessibility-focused tests for core surfaces
48
68
  - [ ] Document a11y guarantees and known gaps
49
69
 
@@ -1,9 +1,11 @@
1
1
  import { Controller } from '@hotwired/stimulus'
2
2
 
3
3
  export default class extends Controller {
4
- static targets = ['tab', 'panel']
4
+ static targets = ['hiddenInput', 'tab', 'panel']
5
5
  static values = { active: String }
6
6
 
7
+ static TAB_KEYS = ['ArrowLeft', 'ArrowRight', 'Home', 'End']
8
+
7
9
  connect() {
8
10
  const initial = this.activeValue || this.tabTargets[0]?.dataset.tabValue
9
11
  if (initial) {
@@ -19,20 +21,69 @@ export default class extends Controller {
19
21
  }
20
22
  }
21
23
 
24
+ handleKeydown(event) {
25
+ if (!this.constructor.TAB_KEYS.includes(event.key) || this.tabTargets.length === 0) return
26
+
27
+ event.preventDefault()
28
+
29
+ const currentIndex = this.tabTargets.indexOf(event.currentTarget)
30
+ if (currentIndex === -1) return
31
+
32
+ let nextIndex = currentIndex
33
+
34
+ if (event.key === 'Home') {
35
+ nextIndex = this.nextEnabledIndex(0, 1)
36
+ } else if (event.key === 'End') {
37
+ nextIndex = this.nextEnabledIndex(this.tabTargets.length - 1, -1)
38
+ } else if (event.key === 'ArrowRight') {
39
+ nextIndex = this.nextEnabledIndex((currentIndex + 1) % this.tabTargets.length, 1)
40
+ } else if (event.key === 'ArrowLeft') {
41
+ nextIndex = this.nextEnabledIndex((currentIndex - 1 + this.tabTargets.length) % this.tabTargets.length, -1)
42
+ }
43
+
44
+ const nextTab = this.tabTargets[nextIndex]
45
+ if (!nextTab) return
46
+
47
+ this.show(nextTab.dataset.tabValue)
48
+ nextTab.focus()
49
+ }
50
+
22
51
  show(value) {
23
52
  this.tabTargets.forEach((tab) => {
24
53
  const selected = tab.dataset.tabValue === value
25
54
  tab.classList.toggle('is-selected', selected)
26
- tab.setAttribute('aria-selected', selected)
55
+ tab.setAttribute('aria-selected', selected ? 'true' : 'false')
27
56
  tab.tabIndex = selected ? 0 : -1
28
57
  })
29
58
 
30
59
  this.panelTargets.forEach((panel) => {
31
60
  const selected = panel.dataset.tabValue === value
32
61
  panel.classList.toggle('hidden', !selected)
62
+ panel.hidden = !selected
33
63
  panel.setAttribute('aria-hidden', (!selected).toString())
34
64
  })
35
65
 
66
+ if (this.hasHiddenInputTarget) {
67
+ this.hiddenInputTarget.value = value
68
+ }
69
+
36
70
  this.activeValue = value
37
71
  }
72
+
73
+ nextEnabledIndex(startIndex, direction) {
74
+ let index = startIndex
75
+
76
+ for (let count = 0; count < this.tabTargets.length; count += 1) {
77
+ const tab = this.tabTargets[index]
78
+ if (tab && !this.isDisabled(tab)) return index
79
+
80
+ index = (index + direction + this.tabTargets.length) % this.tabTargets.length
81
+ }
82
+
83
+ return startIndex
84
+ }
85
+
86
+ isDisabled(tab) {
87
+ return tab.getAttribute('aria-disabled') === 'true' || tab.disabled
88
+ }
38
89
  }
@@ -27,12 +27,13 @@ export default class ThemeController extends Controller {
27
27
  }
28
28
 
29
29
  getCurrentTheme() {
30
- // 1. Check localStorage
31
30
  const stored = getFromStorage(this.storageKeyValue);
32
31
  if (stored && this.themesValue.includes(stored)) return stored;
33
32
 
34
- // 2. Default to light when user has not chosen a theme.
35
- return "light";
33
+ const systemTheme = getSystemPreference("color-scheme", this.themesValue);
34
+ if (systemTheme && this.themesValue.includes(systemTheme)) return systemTheme;
35
+
36
+ return this.themesValue[0] || "light";
36
37
  }
37
38
 
38
39
  handleToggleChange(toggle) {
@@ -4,13 +4,12 @@
4
4
  min-height: 100vh;
5
5
  align-items: center;
6
6
  justify-content: center;
7
- padding: var(--space-6);
8
- background: var(--color-surface-low);
7
+ padding: var(--space-6, 1.5rem);
8
+ background: var(--color-surface-low, var(--color-surface, #f8fafc));
9
9
  }
10
10
 
11
11
  .auth-page__container {
12
- width: 32rem;
13
- max-width: 100%;
12
+ width: min(32rem, 100%);
14
13
  margin-inline: auto;
15
14
  }
16
15
 
@@ -18,12 +17,12 @@
18
17
  display: flex;
19
18
  align-items: center;
20
19
  justify-content: flex-end;
21
- margin-bottom: var(--space-4);
20
+ margin-bottom: var(--space-4, 1rem);
22
21
  }
23
22
 
24
23
  .auth-page__brand {
25
24
  display: flex;
26
25
  justify-content: center;
27
- margin-bottom: var(--space-6);
26
+ margin-bottom: var(--space-6, 1.5rem);
28
27
  }
29
28
  }
@@ -25,7 +25,7 @@
25
25
  .table-card__header {
26
26
  display: flex;
27
27
  flex-wrap: wrap;
28
- align-items: center;
28
+ align-items: flex-start;
29
29
  justify-content: space-between;
30
30
  gap: var(--space-3);
31
31
  padding: var(--space-5) var(--space-6);
@@ -35,7 +35,7 @@
35
35
 
36
36
  .table-card__header-main {
37
37
  min-width: 0;
38
- flex: 1 1 20rem;
38
+ flex: 1 1 auto;
39
39
  }
40
40
 
41
41
  .table-card__header-title-row {
@@ -52,7 +52,8 @@
52
52
 
53
53
  .table-card__header-side {
54
54
  min-width: 0;
55
- flex: 1 1 20rem;
55
+ flex: 0 0 auto;
56
+ align-self: flex-start;
56
57
  display: flex;
57
58
  flex-wrap: wrap;
58
59
  justify-content: flex-end;
@@ -216,13 +217,22 @@
216
217
  background-color: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface-highest));
217
218
  }
218
219
 
219
- .table tbody td:first-child,
220
- .table tbody td:last-child {
220
+ .table tbody td:first-child {
221
+ font-weight: 600;
222
+ }
223
+
224
+ /*
225
+ * Opt-in modifier for tables where the last column should be visually
226
+ * emphasized (e.g. a total or primary action column). Default tables leave
227
+ * the last body cell at normal weight so numeric columns are not
228
+ * unexpectedly bold.
229
+ */
230
+ .table--emphasize-last-column tbody td:last-child {
221
231
  font-weight: 600;
222
232
  }
223
233
 
224
- .table thead th:last-child,
225
- .table tbody td:last-child {
234
+ .table th.text-right,
235
+ .table td.text-right {
226
236
  text-align: right;
227
237
  }
228
238
 
@@ -19,6 +19,10 @@ module Baldur
19
19
  baldur_render 'baldur/components/button', **options
20
20
  end
21
21
 
22
+ def ui_hidden_field_tag(name, value = nil, options = {})
23
+ hidden_field_tag(name, value, options)
24
+ end
25
+
22
26
  def ui_action_row(primary_button:, secondary_button: nil, extra_buttons: [], classes: nil)
23
27
  buttons = []
24
28
  buttons << secondary_button if secondary_button.present?
@@ -214,8 +218,13 @@ module Baldur
214
218
  action: action
215
219
  end
216
220
 
217
- def ui_segmented_buttons(items:, aria_label: 'Tabs', classes: nil)
218
- baldur_render 'baldur/components/segmented_buttons', items: items, aria_label: aria_label, classes: classes
221
+ def ui_segmented_buttons(items:, aria_label: 'Tabs', classes: nil, id: nil, data: nil)
222
+ baldur_render 'baldur/components/segmented_buttons',
223
+ items: items,
224
+ aria_label: aria_label,
225
+ classes: classes,
226
+ id: id,
227
+ data: data
219
228
  end
220
229
 
221
230
  def ui_tooltip(text:, content:, show_icon: true, icon: 'circle-help', variant: :link, wrapper_class: nil,
@@ -19,8 +19,21 @@ module Baldur
19
19
  class_name: class_name
20
20
  end
21
21
 
22
- def ui_snackbar_stack(snackbars: [])
23
- baldur_render 'baldur/components/snackbar_stack', snackbars: normalize_snackbars(snackbars)
22
+ def ui_snackbar_stack(snackbars: [], id: nil, class_name: nil, data: nil)
23
+ baldur_render 'baldur/components/snackbar_stack',
24
+ snackbars: normalize_snackbars(snackbars),
25
+ id: id,
26
+ class_name: class_name,
27
+ data: data
28
+ end
29
+
30
+ def ui_snackbar_turbo_stream(flash, target: 'snackbar-stack', **stack_options)
31
+ raise_missing_turbo_stream_helper! unless respond_to?(:turbo_stream)
32
+
33
+ turbo_stream.update(
34
+ target,
35
+ html: ui_snackbar_stack(snackbars: snackbar_flash_payloads(flash), id: target, **stack_options)
36
+ )
24
37
  end
25
38
 
26
39
  FLASH_SNACKBAR_VARIANTS = { success: :success, notice: :notice, alert: :error, warning: :warning }.freeze
@@ -115,5 +128,9 @@ module Baldur
115
128
  rescue NoMethodError
116
129
  false
117
130
  end
131
+
132
+ def raise_missing_turbo_stream_helper!
133
+ raise ArgumentError, "ui_snackbar_turbo_stream requires turbo-rails and a Turbo Stream view context"
134
+ end
118
135
  end
119
136
  end
@@ -75,6 +75,10 @@
75
75
  data: local_assigns[:data],
76
76
  aria: local_assigns[:aria],
77
77
  form: local_assigns[:form],
78
+ formaction: local_assigns[:formaction],
79
+ formmethod: local_assigns[:formmethod],
80
+ value: local_assigns[:value],
81
+ name: local_assigns[:name],
78
82
  disabled: disabled do %>
79
83
  <%= content %>
80
84
  <% end %>
@@ -1,9 +1,16 @@
1
1
  <%
2
2
  segments = Array(items)
3
3
  return if segments.empty?
4
+ wrapper_classes = ['segmented', classes].compact.join(' ')
5
+ wrapper_data = (local_assigns[:data] || {}).stringify_keys
4
6
  %>
5
- <div class="segmented <%= classes %>" role="tablist" aria-label="<%= aria_label %>">
7
+ <%= tag.div class: wrapper_classes,
8
+ id: local_assigns[:id],
9
+ data: wrapper_data,
10
+ role: 'tablist',
11
+ aria: { label: aria_label } do %>
6
12
  <% segments.each do |segment| %>
13
+ <% segment = segment.respond_to?(:symbolize_keys) ? segment.symbolize_keys : segment %>
7
14
  <% label = segment[:label].to_s %>
8
15
  <% badge_label = segment[:badge_label].to_s.presence %>
9
16
  <% icon = segment[:icon].presence %>
@@ -11,7 +18,9 @@
11
18
  <% disabled = !!segment[:disabled] %>
12
19
  <% value = segment[:value].presence || label.parameterize %>
13
20
  <% icon_only = icon.present? && label.blank? %>
14
- <% data_attributes = (segment[:data] || {}).merge(value: value) %>
21
+ <% data_attributes = (segment[:data] || {}).stringify_keys.merge('value' => value) %>
22
+ <% button_aria = (segment[:aria] || {}).symbolize_keys %>
23
+ <% button_aria = button_aria.reverse_merge(selected: selected, disabled: disabled) %>
15
24
  <% button_classes = [
16
25
  "segmented__button",
17
26
  ("is-selected" if selected),
@@ -22,11 +31,9 @@
22
31
  <% button_options = {
23
32
  type: "button",
24
33
  class: button_classes,
34
+ id: segment[:id],
25
35
  role: "tab",
26
- aria: {
27
- selected: selected,
28
- disabled: disabled
29
- },
36
+ aria: button_aria,
30
37
  tabindex: selected ? 0 : -1,
31
38
  data: data_attributes
32
39
  } %>
@@ -48,4 +55,4 @@
48
55
  <% end %>
49
56
  <% end %>
50
57
  <% end %>
51
- </div>
58
+ <% end %>
@@ -1,8 +1,12 @@
1
- <div class="snackbar-stack"
2
- data-baldur-snackbar-stack
3
- role="status"
4
- aria-live="polite"
5
- aria-atomic="true">
1
+ <%
2
+ stack_classes = ["snackbar-stack", local_assigns[:class_name]].compact.join(" ")
3
+ stack_data = { "baldur-snackbar-stack" => true }.merge((local_assigns[:data] || {}).stringify_keys)
4
+ %>
5
+ <%= tag.div class: stack_classes,
6
+ id: local_assigns[:id],
7
+ data: stack_data,
8
+ role: "status",
9
+ aria: { live: "polite", atomic: "true" } do %>
6
10
  <% Array(snackbars).each do |snackbar| %>
7
11
  <%= render "baldur/components/snackbar", **snackbar %>
8
12
  <% end %>
@@ -10,4 +14,4 @@
10
14
  <template data-baldur-snackbar-template>
11
15
  <%= render "baldur/components/snackbar", template: true %>
12
16
  </template>
13
- </div>
17
+ <% end %>
@@ -14,16 +14,18 @@
14
14
  sort_key = sort_config[:key].to_s
15
15
  sort_direction = sort_config[:direction].to_s.downcase == "asc" ? "asc" : "desc"
16
16
  sort_path_builder = local_assigns[:sort_path_builder]
17
- header_classes = "px-6 py-4 text-left text-xs font-semibold uppercase tracking-wide text-[color:var(--color-on-surface-variant)]"
17
+ header_classes = "px-6 py-4 text-xs font-semibold uppercase tracking-wide text-[color:var(--color-on-surface-variant)]"
18
18
  cell_classes = "px-6 py-4 text-sm text-[color:var(--color-on-surface)]"
19
- table_classes = ["table", local_assigns[:table_class], local_assigns[:class]].compact.join(" ")
19
+ emphasize_last_column = local_assigns[:emphasize_last_column].present?
20
+ table_modifier_class = emphasize_last_column ? "table--emphasize-last-column" : nil
21
+ table_classes = ["table", table_modifier_class, local_assigns[:table_class], local_assigns[:class]].compact.join(" ")
20
22
  %>
21
23
  <div class="table-card__scroll">
22
24
  <table class="<%= table_classes %>">
23
25
  <thead>
24
26
  <tr>
25
27
  <% normalized_columns.each do |column| %>
26
- <% column_header_class = [header_classes, column[:header_class]].compact.join(" ") %>
28
+ <% column_header_class = [header_classes, ("text-right" if column[:numeric]), column[:header_class]].compact.join(" ") %>
27
29
  <% column_sort_key = (column[:sort_key] || column[:key]).to_s %>
28
30
  <% sortable = !!column[:sortable] && sort_path_builder.respond_to?(:call) && column_sort_key.present? %>
29
31
  <% sorted = sortable && sort_key == column_sort_key %>
@@ -106,7 +108,7 @@
106
108
  %>
107
109
  <%= tag.tr(**attributes) do %>
108
110
  <% cells.each_with_index do |cell, index| %>
109
- <% column_cell_class = [cell_classes, normalized_columns[index]&.fetch(:cell_class, nil)].compact.join(" ") %>
111
+ <% column_cell_class = [cell_classes, ("text-right" if normalized_columns[index]&.fetch(:numeric, nil)), normalized_columns[index]&.fetch(:cell_class, nil)].compact.join(" ") %>
110
112
  <td class="<%= column_cell_class %>"><%= cell %></td>
111
113
  <% end %>
112
114
  <% end %>
@@ -4,8 +4,8 @@
4
4
  auth_card_class = local_assigns[:card_class]
5
5
  %>
6
6
 
7
- <div class="<%= auth_shell_classes %>">
8
- <div class="auth-page__container">
7
+ <div class="<%= auth_shell_classes %> min-h-screen flex items-center justify-center p-6 bg-base-200">
8
+ <div class="auth-page__container w-full max-w-lg mx-auto">
9
9
  <% if local_assigns[:top_rail].present? %>
10
10
  <div class="auth-page__top-rail">
11
11
  <%= local_assigns[:top_rail] %>
data/baldur.gemspec CHANGED
@@ -17,8 +17,11 @@ Gem::Specification.new do |spec|
17
17
 
18
18
  spec.files = Dir.chdir(__dir__) do
19
19
  Dir[
20
- '{app,config,lib,script,test}/**/*',
20
+ '{app,config,docs,lib,script,test}/**/*',
21
21
  'README.md',
22
+ 'llms.txt',
23
+ 'llms-full.txt',
24
+ 'context7.json',
22
25
  'TODO.md',
23
26
  'LICENSE',
24
27
  'SECURITY.md',
data/context7.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "$schema": "https://context7.com/schema/context7.json",
3
+ "projectTitle": "Baldur",
4
+ "description": "Opinionated Rails UI engine for Propshaft, importmap-rails, stimulus-rails, and tailwindcss-rails host apps.",
5
+ "folders": [
6
+ "docs"
7
+ ],
8
+ "excludeFiles": [
9
+ "TODO.md"
10
+ ],
11
+ "rules": [
12
+ "Target Rails host apps using Propshaft, importmap-rails, stimulus-rails, and tailwindcss-rails.",
13
+ "Prefer Baldur ui_* helpers over ad hoc duplicated component markup when a helper exists.",
14
+ "Treat optional surfaces as generator-backed and verify install requirements before suggesting their use.",
15
+ "Turbo support is opt-in. ui_snackbar_turbo_stream requires turbo-rails in the host app."
16
+ ]
17
+ }
@@ -0,0 +1,72 @@
1
+ # Alerts and Snackbars
2
+
3
+ ## Alerts
4
+
5
+ Use `ui_alert` for inline status surfaces. Alerts support optional inline actions and opt-in collapsed state:
6
+
7
+ ```erb
8
+ <%= ui_alert(
9
+ variant: :warning,
10
+ title: "Data freshness warning",
11
+ actions: ui_button(label: "Upload Latest Data", href: new_ecommerce_import_path, variant: :primary, size: :sm),
12
+ collapsible: true,
13
+ collapse_key: "tenant-#{current_tenant.id}-executive-pulse-stale-data"
14
+ ) do %>
15
+ <p>Latest available data is 10 days old.</p>
16
+ <% end %>
17
+ ```
18
+
19
+ Collapsed alerts stay inline and can be re-expanded with the built-in `More` summary action.
20
+
21
+ ## Snackbars
22
+
23
+ Use semantic snackbar tones:
24
+
25
+ - `:success` for green success states
26
+ - `:notice` for blue notice/info states
27
+ - `:warning` for amber warning states
28
+ - `:error` for red error states
29
+
30
+ Host apps should map `flash[:notice]` to `:notice` and `flash[:alert]` to `:error` unless they have a stronger semantic signal available.
31
+
32
+ ### Snackbar stack layout
33
+
34
+ Give the global stack an `id` so it can be targeted from the server:
35
+
36
+ ```erb
37
+ <%= ui_snackbar_stack(
38
+ snackbars: snackbar_flash_payloads(flash),
39
+ id: "snackbar-stack"
40
+ ) %>
41
+ ```
42
+
43
+ Use `class_name:` and `data:` to extend the wrapper. Baldur always keeps the base class `snackbar-stack` and the `data-baldur-snackbar-stack` hook.
44
+
45
+ ### Turbo Stream updates
46
+
47
+ For Hotwire apps, call `ui_snackbar_turbo_stream` from a Turbo Stream view to refresh the stack in place:
48
+
49
+ ```erb
50
+ <%# app/views/users/create.turbo_stream.erb %>
51
+ <%= ui_snackbar_turbo_stream(flash) %>
52
+ ```
53
+
54
+ This is the same as:
55
+
56
+ ```erb
57
+ <%= turbo_stream.update(
58
+ "snackbar-stack",
59
+ html: ui_snackbar_stack(
60
+ snackbars: snackbar_flash_payloads(flash),
61
+ id: "snackbar-stack"
62
+ )
63
+ ) %>
64
+ ```
65
+
66
+ Change the target with `target:`:
67
+
68
+ ```erb
69
+ <%= ui_snackbar_turbo_stream(flash, target: "frame-snackbar-stack") %>
70
+ ```
71
+
72
+ `ui_snackbar_turbo_stream` is opt-in. If the host app does not use `turbo-rails`, calling it raises a clear `ArgumentError` at runtime. Non-Turbo apps can continue using `ui_snackbar_stack` with flash payloads the normal way.
data/docs/auth.md ADDED
@@ -0,0 +1,66 @@
1
+ # Auth
2
+
3
+ ## When to Use
4
+
5
+ Use `ui_auth_page` for sign-in, sign-up, and password-reset layouts. It is available after base install.
6
+
7
+ ## Quick Example
8
+
9
+ ```erb
10
+ <%= ui_auth_page(title: "Sign in", description: nil, brand_path: root_path) do %>
11
+ <%= yield %>
12
+ <% end %>
13
+ ```
14
+
15
+ ## Flash Messages
16
+
17
+ Auth flash messaging can be rendered inside the card by passing `notice:` / `alert:`:
18
+
19
+ ```erb
20
+ <%= ui_auth_page(title: "Sign in", description: nil, brand_path: root_path, notice: notice, alert: alert) do %>
21
+ <%= yield %>
22
+ <% end %>
23
+ ```
24
+
25
+ ## Top Rail
26
+
27
+ Pass `top_rail:` to add an action bar above the brand lockup. The most common use is a theme toggle:
28
+
29
+ ```erb
30
+ <%= ui_auth_page(
31
+ title: "Sign in",
32
+ description: nil,
33
+ brand_path: root_path,
34
+ top_rail: ui_theme_toggle,
35
+ notice: notice,
36
+ alert: alert
37
+ ) do %>
38
+ <%= yield %>
39
+ <% end %>
40
+ ```
41
+
42
+ The `top_rail` slot accepts any rendered content, so it works for other controls too:
43
+
44
+ ```erb
45
+ <%= ui_auth_page(
46
+ title: "Sign in",
47
+ description: nil,
48
+ brand_path: root_path,
49
+ top_rail: "<span class='text-sm text-base-content/60'>v2.1.0</span>".html_safe
50
+ ) do %>
51
+ <%= yield %>
52
+ <% end %>
53
+ ```
54
+
55
+ The host must mount `data-controller="theme"` on a parent element (typically `<body>`) for the theme toggle to function. See `docs/theme.md` for full controller documentation.
56
+
57
+ ## Theme Toggle
58
+
59
+ `ui_theme_toggle` renders a light/dark switch wired to the Baldur theme controller:
60
+
61
+ ```erb
62
+ <%= ui_theme_toggle %>
63
+ <%= ui_theme_toggle(aria_label: "Switch appearance", classes: "my-extra-class") %>
64
+ ```
65
+
66
+ Requires `data-controller="theme"` on a parent element. See `docs/theme.md` for setup.