plutonium 0.66.0 → 0.68.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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-dashboard/SKILL.md +17 -2
  3. data/.claude/skills/plutonium-resource/SKILL.md +17 -10
  4. data/.claude/skills/plutonium-ui/SKILL.md +13 -3
  5. data/CHANGELOG.md +35 -0
  6. data/SECURITY.md +1 -1
  7. data/app/assets/plutonium.css +1 -1
  8. data/app/assets/plutonium.js +56 -3
  9. data/app/assets/plutonium.js.map +4 -4
  10. data/app/assets/plutonium.min.js +32 -32
  11. data/app/assets/plutonium.min.js.map +4 -4
  12. data/config/brakeman.ignore +1 -1
  13. data/config/locales/en/dashboard.yml +1 -0
  14. data/config/locales/en/js.yml +4 -0
  15. data/config/locales/en/ui.yml +19 -0
  16. data/docs/reference/dashboard/dsl.md +54 -3
  17. data/docs/reference/resource/definition.md +14 -7
  18. data/docs/reference/resource/query.md +11 -1
  19. data/docs/reference/ui/displays.md +19 -2
  20. data/docs/reference/ui/forms.md +77 -4
  21. data/gemfiles/rails_7.gemfile +1 -0
  22. data/gemfiles/rails_7.gemfile.lock +13 -3
  23. data/gemfiles/rails_8.0.gemfile +1 -0
  24. data/gemfiles/rails_8.0.gemfile.lock +10 -1
  25. data/gemfiles/rails_8.1.gemfile +1 -0
  26. data/gemfiles/rails_8.1.gemfile.lock +10 -1
  27. data/lib/plutonium/auth/rodauth.rb +7 -0
  28. data/lib/plutonium/core/controller.rb +0 -2
  29. data/lib/plutonium/dashboard/base.rb +80 -1
  30. data/lib/plutonium/dashboard/card.rb +12 -13
  31. data/lib/plutonium/dashboard/controller.rb +28 -10
  32. data/lib/plutonium/dashboard/dsl.rb +59 -2
  33. data/lib/plutonium/dashboard/evaluation.rb +30 -0
  34. data/lib/plutonium/dashboard/filter.rb +114 -0
  35. data/lib/plutonium/dashboard/link.rb +35 -0
  36. data/lib/plutonium/dashboard/route_resolution.rb +6 -2
  37. data/lib/plutonium/helpers/content_helper.rb +0 -3
  38. data/lib/plutonium/query/filter.rb +2 -1
  39. data/lib/plutonium/query/filters/tags.rb +36 -0
  40. data/lib/plutonium/resource/query_object.rb +6 -0
  41. data/lib/plutonium/routing/dashboard_registration.rb +5 -0
  42. data/lib/plutonium/ui/avatar.rb +2 -2
  43. data/lib/plutonium/ui/component/resolves_tags.rb +13 -0
  44. data/lib/plutonium/ui/dashboard/board.rb +4 -1
  45. data/lib/plutonium/ui/dashboard/frame.rb +1 -1
  46. data/lib/plutonium/ui/dashboard/metric.rb +18 -5
  47. data/lib/plutonium/ui/dashboard/toolbar.rb +76 -0
  48. data/lib/plutonium/ui/display/base.rb +50 -2
  49. data/lib/plutonium/ui/display/components/badge.rb +1 -1
  50. data/lib/plutonium/ui/display/components/binary.rb +20 -0
  51. data/lib/plutonium/ui/display/components/boolean.rb +1 -1
  52. data/lib/plutonium/ui/display/components/code.rb +23 -0
  53. data/lib/plutonium/ui/display/components/color.rb +14 -6
  54. data/lib/plutonium/ui/display/components/duration.rb +31 -0
  55. data/lib/plutonium/ui/display/components/file_size.rb +22 -0
  56. data/lib/plutonium/ui/display/components/key_value.rb +37 -0
  57. data/lib/plutonium/ui/display/components/list.rb +33 -0
  58. data/lib/plutonium/ui/display/components/number.rb +32 -0
  59. data/lib/plutonium/ui/display/components/progress.rb +36 -0
  60. data/lib/plutonium/ui/display/components/rating.rb +36 -0
  61. data/lib/plutonium/ui/display/components/relative_time.rb +25 -0
  62. data/lib/plutonium/ui/display/components/rich_text.rb +23 -0
  63. data/lib/plutonium/ui/display/components/tags.rb +18 -0
  64. data/lib/plutonium/ui/display/options/inferred_types.rb +13 -0
  65. data/lib/plutonium/ui/display/theme.rb +24 -2
  66. data/lib/plutonium/ui/form/base.rb +50 -1
  67. data/lib/plutonium/ui/form/components/binary.rb +40 -0
  68. data/lib/plutonium/ui/form/components/key_value_store.rb +22 -71
  69. data/lib/plutonium/ui/form/components/list.rb +46 -0
  70. data/lib/plutonium/ui/form/components/rating.rb +51 -0
  71. data/lib/plutonium/ui/form/components/rich_text.rb +20 -0
  72. data/lib/plutonium/ui/form/components/secure_association.rb +31 -8
  73. data/lib/plutonium/ui/form/components/secure_association_checkboxes.rb +30 -0
  74. data/lib/plutonium/ui/form/components/secure_association_choices.rb +123 -0
  75. data/lib/plutonium/ui/form/components/secure_association_radio_buttons.rb +45 -0
  76. data/lib/plutonium/ui/form/components/secure_association_toggles.rb +18 -0
  77. data/lib/plutonium/ui/form/components/tags.rb +45 -0
  78. data/lib/plutonium/ui/form/components/tags_filter.rb +40 -0
  79. data/lib/plutonium/ui/form/components/uppy.rb +1 -1
  80. data/lib/plutonium/ui/form/options/inferred_types.rb +16 -15
  81. data/lib/plutonium/ui/form/theme.rb +34 -4
  82. data/lib/plutonium/ui/grid/card.rb +25 -5
  83. data/lib/plutonium/ui/layout/base.rb +10 -1
  84. data/lib/plutonium/ui/options/rich_text_field.rb +18 -0
  85. data/lib/plutonium/ui/options/secret_field.rb +32 -0
  86. data/lib/plutonium/ui/options/tags_field.rb +23 -0
  87. data/lib/plutonium/ui/page/dashboard.rb +1 -0
  88. data/lib/plutonium/ui/table/components/scopes_bar.rb +1 -1
  89. data/lib/plutonium/ui/table/components/scopes_pills.rb +1 -1
  90. data/lib/plutonium/ui/table/components/toolbar.rb +2 -2
  91. data/lib/plutonium/ui/table/components/view_switcher.rb +4 -2
  92. data/lib/plutonium/ui/table/display_theme.rb +30 -2
  93. data/lib/plutonium/version.rb +1 -1
  94. data/package.json +1 -1
  95. data/plutonium.gemspec +1 -1
  96. data/src/css/components.css +43 -0
  97. data/src/css/flatpickr.css +91 -0
  98. data/src/js/controllers/easymde_controller.js +1 -0
  99. data/src/js/controllers/register_controllers.js +2 -0
  100. data/src/js/controllers/slim_select_controller.js +32 -3
  101. data/src/js/controllers/timeago_controller.js +40 -0
  102. metadata +35 -4
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ module Components
7
+ # A belongs_to / has_one association rendered as one radio button per
8
+ # authorized record (`input :category, as: :association_radio_buttons`).
9
+ #
10
+ # An optional association also gets a blank "None" choice, since a
11
+ # radio group can't otherwise be cleared once something is picked.
12
+ # `include_blank: false` drops it. A required association never gets
13
+ # one: a checked "None" would satisfy the group's `required`.
14
+ class SecureAssociationRadioButtons < SecureAssociationChoices
15
+ protected
16
+
17
+ def control_type = :radio
18
+
19
+ def control_theme = :association_radio_button
20
+
21
+ def supported_macros = %i[belongs_to has_one]
22
+
23
+ def group_role = "radiogroup"
24
+
25
+ def include_blank?
26
+ return false if attributes[:required]
27
+
28
+ @include_blank != false
29
+ end
30
+
31
+ # `required` on any radio in a group makes picking one of them required.
32
+ def control_required? = attributes[:required]
33
+
34
+ def render_blank_choice
35
+ render_choice(value: "", text: blank_choice_text, id: "#{attributes[:id]}_blank", checked: singular_field_value.nil?)
36
+ end
37
+
38
+ def blank_choice_text
39
+ field.placeholder.presence || Plutonium::Translation.t("plutonium.ui.form.association_choices.none")
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ module Components
7
+ # Switch-styled {SecureAssociationCheckboxes}
8
+ # (`input :tags, as: :association_toggles`). Same behavior; only the
9
+ # `.pu-toggle` styling differs.
10
+ class SecureAssociationToggles < SecureAssociationCheckboxes
11
+ protected
12
+
13
+ def control_theme = :association_toggle
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ module Components
7
+ # Chip input for a no_fly_list `has_tags` context. Suggests the tags
8
+ # that already exist in the record's tag scope (its tenant), follows
9
+ # the context's `restrict_to_existing` and `limit`, and submits to the
10
+ # `<context>_list` writer.
11
+ class Tags < List
12
+ SUGGESTION_LIMIT = 200
13
+
14
+ protected
15
+
16
+ def build_attributes
17
+ context = Plutonium::UI::Options::TagsField.context(field.object, field.key)
18
+ attributes[:input_param] ||= :"#{field.key}_list"
19
+ attributes[:addable] = !context[:restrict_to_existing] unless attributes.key?(:addable)
20
+ attributes[:limit] ||= context[:limit]
21
+ attributes[:suggestions] ||= existing_tags(context)
22
+ super
23
+ end
24
+
25
+ # The proxy, not the association: on a failed save it still holds
26
+ # what was submitted.
27
+ def current_values
28
+ field.object.public_send(:"#{field.key}_list").to_a
29
+ end
30
+
31
+ private
32
+
33
+ def existing_tags(context)
34
+ tags = context[:tag_class_name].constantize.order(:name).limit(SUGGESTION_LIMIT)
35
+ if (scope = context[:scope])
36
+ column = field.object.class.reflect_on_association(scope)&.foreign_key || scope
37
+ tags = tags.where(column => field.object[column])
38
+ end
39
+ tags.pluck(:name)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ module Components
7
+ # The {Plutonium::Query::Filters::Tags} input. Offers only the tags on
8
+ # records the user can see (the policy scope), so a tenant never sees
9
+ # another tenant's vocabulary and every option matches something.
10
+ class TagsFilter < List
11
+ include Plutonium::UI::Component::Methods
12
+
13
+ protected
14
+
15
+ def build_attributes
16
+ @context = attributes.delete(:context)
17
+ attributes[:addable] = false
18
+ attributes[:data_controller] = tokens(attributes[:data_controller], "slim-select")
19
+ attributes[:class] = ""
20
+ super
21
+ end
22
+
23
+ # Built at render time: the policy scope needs the view context,
24
+ # which doesn't exist yet when attributes are built.
25
+ def choices
26
+ @choices ||= build_choice_mapper(current_values | visible_tags)
27
+ end
28
+
29
+ private
30
+
31
+ def visible_tags
32
+ tag_class = Plutonium::UI::Options::TagsField.context(resource_class.new, @context)[:tag_class_name].constantize
33
+ name = tag_class.arel_table[:name]
34
+ authorized_resource_scope(resource_class).unscope(:order, :select).joins(@context).distinct.order(name).pluck(name)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -155,7 +155,7 @@ module Plutonium
155
155
  class: "w-full py-2 px-4 text-sm text-danger-600 dark:text-danger-400 bg-[var(--pu-surface)] hover:bg-danger-50 dark:hover:bg-danger-900/50 rounded-b-[var(--pu-radius-md)] transition-colors duration-200 flex items-center justify-center gap-2 border-t border-[var(--pu-border)]",
156
156
  data: {action: "click->attachment-preview#remove"}
157
157
  ) do
158
- span(class: "bi bi-trash")
158
+ render Phlex::TablerIcons::Trash.new(class: "w-4 h-4")
159
159
  plain Plutonium::Translation.t("plutonium.ui.form.attachment.delete")
160
160
  end
161
161
  end
@@ -6,16 +6,23 @@ module Plutonium
6
6
  module Options
7
7
  module InferredTypes
8
8
  include Plutonium::UI::Options::HasCentsField
9
+ include Plutonium::UI::Options::SecretField
10
+ include Plutonium::UI::Options::TagsField
11
+ include Plutonium::UI::Options::RichTextField
9
12
 
10
13
  private
11
14
 
12
15
  def infer_field_component
16
+ # A tag context is also a has_many of tag records; claim it first.
17
+ return :tags if tags_field?
18
+ return :rich_text if rich_text_field?
19
+
13
20
  # Password detection lives in the string-type inference, not the
14
21
  # component-type inference (a `password` column infers as :string).
15
22
  # Route every inferred password/secret field to the masking Password
16
23
  # component so the stored value never reaches the DOM. We also widen
17
24
  # the heuristic to secret-bearing names Phlexi misses (`*_secret`,
18
- # `*_key`, `salt`, ...) — see #secret_field_name?.
25
+ # `*_key`, `salt`, ...) — see Options::SecretField.
19
26
  return :password if inferred_string_field_type == :password || secret_field_name?
20
27
 
21
28
  # has_cents decimal accessors render as a currency input (number field
@@ -23,8 +30,14 @@ module Plutonium
23
30
  return :currency if has_cents_field?
24
31
 
25
32
  case inferred_field_type
26
- when :rich_text
27
- return :markdown
33
+ when :binary
34
+ # phlexi-form infers :file, which Plutonium renders as an Uppy
35
+ # attachment; a binary column holds the file's bytes instead.
36
+ return :binary
37
+ when :json, :jsonb
38
+ # phlexi-form infers these as a plain :text textarea, which
39
+ # renders `Hash#to_s` and never parses the submission back.
40
+ return :json
28
41
  end
29
42
 
30
43
  inferred_field_component = super
@@ -39,18 +52,6 @@ module Plutonium
39
52
  inferred_field_component
40
53
  end
41
54
  end
42
-
43
- # Secret-bearing names Phlexi's `is_password_field?` does not catch
44
- # (it only handles `password`, `encrypted_*`, `*_password`, `*_digest`,
45
- # `*_hash`, `*_token`). Mask these too so their value never reaches the
46
- # DOM. Still a name heuristic, not a guarantee — opt in/out per field
47
- # with `as: :password` / `as: :string`.
48
- def secret_field_name?
49
- name = key.to_s.downcase
50
- name == "token" || name == "salt" ||
51
- name.include?("secret") ||
52
- name.end_with?("_key", "_salt")
53
- end
54
55
  end
55
56
  end
56
57
  end
@@ -117,6 +117,22 @@ module Plutonium
117
117
  invalid_json: :invalid_input,
118
118
  neutral_json: :neutral_input,
119
119
 
120
+ # EasyMDE replaces the textarea once its JS boots; until then (or
121
+ # without JS) the textarea should look like every other one.
122
+ easymde: :textarea,
123
+ valid_easymde: :valid_textarea,
124
+ invalid_easymde: :invalid_textarea,
125
+ neutral_easymde: :neutral_textarea,
126
+
127
+ # Action Text editor: Trix / Lexxy style themselves.
128
+ rich_text: "",
129
+
130
+ # Star picker: styled by .pu-rating (components.css).
131
+ rating: "",
132
+
133
+ # Key/value editor: the component lays out its own rows and inputs.
134
+ key_value_store: "",
135
+
120
136
  # Uppy file upload
121
137
  uppy: :file,
122
138
  valid_uppy: :valid_file,
@@ -130,10 +146,24 @@ module Plutonium
130
146
  neutral_association: :neutral_select,
131
147
 
132
148
  # Polymorphic association
133
- polymorpic_association: :association,
134
- valid_polymorpic_association: :valid_association,
135
- invalid_polymorpic_association: :invalid_association,
136
- neutral_polymorpic_association: :neutral_association
149
+ polymorphic_association: :association,
150
+ valid_polymorphic_association: :valid_association,
151
+ invalid_polymorphic_association: :invalid_association,
152
+ neutral_polymorphic_association: :neutral_association,
153
+
154
+ # List-style association variants (`as: :association_checkboxes`,
155
+ # `:association_toggles`, `:association_radio_buttons`). The bare key
156
+ # styles the list; `association_<control>` styles each control.
157
+ association_checkboxes: "flex flex-col gap-2",
158
+ association_toggles: "flex flex-col gap-2",
159
+ association_radio_buttons: "flex flex-col gap-2",
160
+ association_choice_label: "inline-flex items-center gap-2 text-sm text-[var(--pu-text)] cursor-pointer",
161
+ association_checkbox: :checkbox,
162
+ invalid_association_checkbox: :invalid_boolean,
163
+ association_toggle: :toggle,
164
+ invalid_association_toggle: :invalid_toggle,
165
+ association_radio_button: :radio_button,
166
+ invalid_association_radio_button: "pu-radio pu-input-invalid"
137
167
  })
138
168
  end
139
169
  end
@@ -105,13 +105,21 @@ module Plutonium
105
105
  value = field_value(slots[:image])
106
106
 
107
107
  if size == :cover
108
- # Cover is a full-width banner, not an avatar: only render when an
109
- # actual image resolves (no deterministic fallback).
108
+ # Cover is a full-width banner, not an avatar, so a record with no
109
+ # image gets no generated stand-in. It still gets the banner's
110
+ # space, as an empty slot elsewhere gets its em-dash: without it the
111
+ # card is a banner shorter than its neighbours, and the row stretches
112
+ # it into a block of blank card under its text.
110
113
  src = Plutonium::UI::Avatar.resolve_image_src(value, helpers)
111
- return unless src
112
114
 
113
115
  div(class: "w-full aspect-video bg-[var(--pu-surface-alt)] overflow-hidden") do
114
- img(src: src, alt: header_text.to_s, class: "w-full h-full object-cover")
116
+ if src
117
+ img(src: src, alt: header_text.to_s, class: "w-full h-full object-cover")
118
+ else
119
+ div(class: "w-full h-full flex items-center justify-center text-[var(--pu-text-subtle)]", aria_hidden: "true") do
120
+ render Phlex::TablerIcons::Photo.new(class: "w-8 h-8")
121
+ end
122
+ end
115
123
  end
116
124
  else
117
125
  # Small avatar slot: render the resolved image, or Avatar's generic
@@ -314,7 +322,19 @@ module Plutonium
314
322
  "#{record.class.name} doesn't respond to it. " \
315
323
  "Define the method on the model or remove the slot."
316
324
  end
317
- record.public_send(name)
325
+ value = record.public_send(name)
326
+ # Slots render raw values, so mask secrets here. Blank stays blank so
327
+ # the slot still collapses.
328
+ (value.present? && secret_slot?(name)) ? Plutonium::UI::Options::SecretField::MASK : value
329
+ end
330
+
331
+ # Same rule as the show page: an explicit `as:` (display over field)
332
+ # decides; otherwise the secret-name heuristic does.
333
+ def secret_slot?(name)
334
+ name = name.to_sym
335
+ declared = resource_definition.defined_displays.dig(name, :options, :as) ||
336
+ resource_definition.defined_fields.dig(name, :options, :as)
337
+ declared ? %i[password secret].include?(declared.to_sym) : Plutonium::UI::Options::SecretField.secret_name?(name)
318
338
  end
319
339
 
320
340
  def row_actions
@@ -241,7 +241,16 @@ module Plutonium
241
241
  href:
242
242
  "https://cdn.jsdelivr.net/npm/easymde@2.18.0/dist/easymde.min.css",
243
243
  integrity:
244
- "uqD/OYCNfagd1EgXMgl5QedTD5K+B3e9b8GYo/41t7+Serf7CBxvl+tU1gHd+qd1",
244
+ "sha384-uqD/OYCNfagd1EgXMgl5QedTD5K+B3e9b8GYo/41t7+Serf7CBxvl+tU1gHd+qd1",
245
+ crossorigin: "anonymous"
246
+ )
247
+ # EasyMDE's toolbar uses Font Awesome 4 class names (renamed in 5+).
248
+ link(
249
+ rel: "stylesheet",
250
+ href:
251
+ "https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css",
252
+ integrity:
253
+ "sha384-wvfXpqpZZVQGK6TAh5PVlGOfQNHSoD2xbE+QkPxCAFlNEevoEH3Sl0sibVcOQVnN",
245
254
  crossorigin: "anonymous"
246
255
  )
247
256
  end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Options
6
+ # Detects `has_rich_text` fields by the `rich_text_<name>` association
7
+ # Action Text defines. phlexi-field stopped inferring :rich_text, so
8
+ # without this they fall through to a plain string.
9
+ module RichTextField
10
+ private
11
+
12
+ def rich_text_field?
13
+ object.is_a?(ActiveRecord::Base) && object.class.reflect_on_association(:"rich_text_#{key}").present?
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Options
6
+ # Shared detector for secret-bearing field names, so every surface masks
7
+ # the same fields: the form (Password input), the show page / table /
8
+ # wizard summary (masked display) and grid / kanban card slots.
9
+ #
10
+ # Covers Phlexi's password names (`password`, `encrypted_*`, `*_password`,
11
+ # `*_digest`, `*_hash`, `*_token`) plus ones it misses (`token`, `salt`,
12
+ # `*_salt`, `*_key`, anything containing `secret`). Still a name
13
+ # heuristic, not a guarantee: opt a field in or out with `as: :password`
14
+ # / `as: :string`.
15
+ module SecretField
16
+ MASK = "••••••••"
17
+
18
+ def self.secret_name?(name)
19
+ name = name.to_s.downcase
20
+ %w[password token salt].include?(name) ||
21
+ name.start_with?("encrypted_") ||
22
+ name.end_with?("_password", "_digest", "_hash", "_token", "_salt", "_key") ||
23
+ name.include?("secret")
24
+ end
25
+
26
+ private
27
+
28
+ def secret_field_name? = SecretField.secret_name?(key)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Options
6
+ # Detects no_fly_list `has_tags` contexts. A context named `labels`
7
+ # defines a `labels` has_many (the tag records) next to the
8
+ # `labels_list` proxy, so without this it infers as an association.
9
+ # no_fly_list is optional: without the gem nothing is a tag field.
10
+ module TagsField
11
+ def self.context(object, key)
12
+ return unless defined?(NoFlyList::TaggableRecord) && object.is_a?(NoFlyList::TaggableRecord)
13
+
14
+ object.class._no_fly_list.tag_contexts[key.to_sym]
15
+ end
16
+
17
+ private
18
+
19
+ def tags_field? = TagsField.context(object, key).present?
20
+ end
21
+ end
22
+ end
23
+ end
@@ -36,6 +36,7 @@ module Plutonium
36
36
  attr_reader :dashboard
37
37
 
38
38
  def render_content
39
+ render Plutonium::UI::Dashboard::Toolbar.new(dashboard:)
39
40
  render Plutonium::UI::Dashboard::Board.new(dashboard:)
40
41
  end
41
42
 
@@ -42,7 +42,7 @@ module Plutonium
42
42
  end
43
43
 
44
44
  def scope_label(name)
45
- Plutonium::Translation.label_for(:scope, current_query_object.resource_class, name) || name.to_s.humanize
45
+ current_query_object.scope_label(name)
46
46
  end
47
47
 
48
48
  def current_scope
@@ -32,7 +32,7 @@ module Plutonium
32
32
 
33
33
  def render_pill(key)
34
34
  active = current_query_object.selected_scope.to_s == key.to_s
35
- label = Plutonium::Translation.label_for(:scope, current_query_object.resource_class, key) || key.to_s.humanize
35
+ label = current_query_object.scope_label(key)
36
36
 
37
37
  a(
38
38
  id: "#{key}-scope",
@@ -80,7 +80,7 @@ module Plutonium
80
80
  end
81
81
 
82
82
  def render_search
83
- form(method: :get, action: @search_url) do
83
+ form(method: :get, action: @search_url, class: "min-w-0") do
84
84
  div(class: "relative") do
85
85
  div(class: "absolute inset-y-0 left-0 flex items-center pl-2 pointer-events-none") do
86
86
  render Phlex::TablerIcons::Search.new(class: "w-4 h-4 text-[var(--pu-text-muted)]")
@@ -91,7 +91,7 @@ module Plutonium
91
91
  name: "#{@search_param}[search]",
92
92
  value: @search_value,
93
93
  placeholder: t("plutonium.ui.table.search_placeholder"),
94
- class: "pu-input pu-input-toolbar pu-input-icon-left w-[220px]",
94
+ class: "pu-input pu-input-toolbar pu-input-icon-left w-[220px] max-w-full",
95
95
  # turbo-permanent + a stable id keep the DOM node
96
96
  # across Turbo morphs so focus / caret / IME state
97
97
  # survive the search-as-you-type submit cycle.
@@ -35,7 +35,7 @@ module Plutonium
35
35
  div(
36
36
  role: "tablist",
37
37
  aria: {label: t("plutonium.ui.table.view")},
38
- class: "inline-flex h-8 rounded-md border border-[var(--pu-border)] bg-[var(--pu-surface)] overflow-hidden",
38
+ class: "inline-flex shrink-0 h-8 rounded-md border border-[var(--pu-border)] bg-[var(--pu-surface)] overflow-hidden",
39
39
  data: {
40
40
  controller: "view-switcher",
41
41
  view_switcher_cookie_name_value: @cookie_name,
@@ -76,7 +76,9 @@ module Plutonium
76
76
  }
77
77
  ) do
78
78
  render icon.new(class: "w-4 h-4 shrink-0")
79
- span { label }
79
+ # Icon-only below sm so the switcher fits narrow toolbars; the
80
+ # label stays in the accessibility tree via sr-only.
81
+ span(class: "sr-only sm:not-sr-only") { label }
80
82
  end
81
83
  end
82
84
  end
@@ -9,10 +9,38 @@ module Plutonium
9
9
  value_wrapper: "max-h-[150px] overflow-y-auto",
10
10
  prefixed_icon: "w-4 h-4 mr-1 text-[var(--pu-text-muted)]",
11
11
  link: "text-primary-600 dark:text-primary-400 hover:text-primary-500 dark:hover:text-primary-300 transition-colors",
12
- color: "flex items-center",
13
- color_indicator: "w-8 h-8 rounded-md mr-2 shadow-sm border border-[var(--pu-border)]",
12
+ color: "flex items-center gap-2",
13
+ color_indicator: "w-6 h-6 rounded border border-[var(--pu-border)]",
14
+ color_label: "text-sm text-[var(--pu-text-muted)]",
14
15
  email: "flex items-center gap-1 text-primary-600 dark:text-primary-400 hover:text-primary-500 whitespace-nowrap transition-colors",
15
16
  phone: "flex items-center gap-1 text-primary-600 dark:text-primary-400 hover:text-primary-500 whitespace-nowrap transition-colors",
17
+ markdown: "format format-sm dark:format-invert format-primary max-w-none",
18
+ rich_text: "format format-sm dark:format-invert format-primary max-w-none",
19
+ currency: "tabular-nums",
20
+ # Boolean / badge pills: the component applies the variant class.
21
+ boolean: "",
22
+ badge: "",
23
+ key_value: "grid grid-cols-[auto_1fr] w-max gap-x-3 gap-y-0.5 text-sm",
24
+ key_value_key: "font-medium text-[var(--pu-text-muted)] break-words",
25
+ key_value_value: "break-words",
26
+ key_value_nested: "font-mono text-xs",
27
+ list: "flex flex-wrap gap-1",
28
+ list_item: "pu-badge pu-badge-neutral",
29
+ relative_time: "text-sm text-[var(--pu-text)]",
30
+ code: "flex items-center gap-2",
31
+ code_value: "font-mono text-sm text-[var(--pu-text)] bg-[var(--pu-surface-alt)] border border-[var(--pu-border-muted)] rounded-[var(--pu-radius-sm)] px-2 py-0.5 break-all",
32
+ code_copy: "pu-btn pu-btn-xs pu-btn-ghost",
33
+ duration: "text-sm text-[var(--pu-text)] tabular-nums",
34
+ file_size: "text-sm text-[var(--pu-text)] tabular-nums",
35
+ progress: "flex items-center gap-2",
36
+ progress_track: "h-2 w-full max-w-xs rounded-full bg-[var(--pu-surface-alt)] overflow-hidden",
37
+ progress_bar: "h-full rounded-full bg-primary-500",
38
+ progress_label: "text-sm text-[var(--pu-text-muted)] tabular-nums",
39
+ rating: "flex items-center gap-0.5",
40
+ rating_filled: "w-4 h-4 text-warning-500 fill-current",
41
+ rating_empty: "w-4 h-4 text-[var(--pu-border)]",
42
+ binary: "text-sm text-[var(--pu-text-muted)]",
43
+ phlexi_render: :string,
16
44
  json: "whitespace-pre font-mono text-xs bg-[var(--pu-surface-alt)] border border-[var(--pu-border-muted)] rounded-[var(--pu-radius-sm)] p-2 overflow-x-auto",
17
45
  attachment_value_wrapper: "flex flex-wrap gap-1"
18
46
  })
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.66.0"
2
+ VERSION = "0.68.0"
3
3
  NEXT_MAJOR_VERSION = VERSION.split(".").tap { |v|
4
4
  v[1] = v[1].to_i + 1
5
5
  v[2] = 0
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@radioactive-labs/plutonium",
3
- "version": "0.66.0",
3
+ "version": "0.68.0",
4
4
  "description": "Build production-ready Rails apps in minutes, not days. Convention-driven, fully customizable, AI-ready.",
5
5
  "type": "module",
6
6
  "main": "src/js/core.js",
data/plutonium.gemspec CHANGED
@@ -60,7 +60,7 @@ Gem::Specification.new do |spec|
60
60
  spec.add_dependency "phlex-rails"
61
61
  spec.add_dependency "phlex-tabler_icons"
62
62
  spec.add_dependency "phlexi-field", ">= 0.2.0"
63
- spec.add_dependency "phlexi-form", ">= 0.14.3"
63
+ spec.add_dependency "phlexi-form", ">= 0.14.4"
64
64
  spec.add_dependency "phlexi-table", ">= 0.2.0"
65
65
  spec.add_dependency "phlexi-display", ">= 0.2.0"
66
66
  spec.add_dependency "phlexi-menu", ">= 0.4.1"
@@ -1247,3 +1247,46 @@ html.pu-rail-pinned .icon-rail-pin-expand {
1247
1247
  opacity: 0.6;
1248
1248
  }
1249
1249
  }
1250
+
1251
+ /* Star rating input (Form::Components::Rating). Radios run max..1 in a
1252
+ row-reversed box, so `~ label` reaches the stars below the checked or
1253
+ hovered one. */
1254
+ .pu-rating {
1255
+ @apply inline-flex flex-row-reverse justify-end gap-0.5 border-0 p-0 m-0;
1256
+ }
1257
+ .pu-rating label {
1258
+ @apply cursor-pointer text-[var(--pu-border)] transition-colors;
1259
+ }
1260
+ .pu-rating label svg {
1261
+ @apply w-6 h-6;
1262
+ }
1263
+ .pu-rating input:checked ~ label,
1264
+ .pu-rating label:hover,
1265
+ .pu-rating label:hover ~ label {
1266
+ @apply text-warning-500;
1267
+ }
1268
+ .pu-rating input:checked ~ label svg,
1269
+ .pu-rating label:hover svg,
1270
+ .pu-rating label:hover ~ label svg {
1271
+ fill: currentColor;
1272
+ }
1273
+ /* "Clear" comes last in the DOM; order: -1 puts it after the stars
1274
+ (row-reverse lays out from the right). `input ~` matches the star-fill
1275
+ rules' specificity, and coming later lets this win. */
1276
+ .pu-rating input ~ label.pu-rating-clear {
1277
+ @apply order-[-1] ml-2 self-center text-sm text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] hover:underline;
1278
+ }
1279
+ .pu-rating input:focus-visible + label {
1280
+ @apply rounded-sm outline-2 outline-offset-2 outline-primary-500;
1281
+ }
1282
+
1283
+ /* Tailwind's preflight strips list markers. Restore them inside the rich
1284
+ text editors (Trix, Lexxy) so lists look like lists while editing. */
1285
+ trix-editor ul,
1286
+ .lexxy-content ul {
1287
+ @apply list-disc ps-6;
1288
+ }
1289
+ trix-editor ol,
1290
+ .lexxy-content ol {
1291
+ @apply list-decimal ps-6;
1292
+ }