plutonium 0.66.0 → 0.67.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-resource/SKILL.md +14 -8
  3. data/.claude/skills/plutonium-ui/SKILL.md +11 -3
  4. data/CHANGELOG.md +22 -0
  5. data/SECURITY.md +1 -1
  6. data/app/assets/plutonium.css +1 -1
  7. data/app/assets/plutonium.js +56 -3
  8. data/app/assets/plutonium.js.map +4 -4
  9. data/app/assets/plutonium.min.js +32 -32
  10. data/app/assets/plutonium.min.js.map +4 -4
  11. data/config/brakeman.ignore +1 -1
  12. data/config/locales/en/js.yml +4 -0
  13. data/config/locales/en/ui.yml +16 -0
  14. data/docs/reference/resource/definition.md +12 -7
  15. data/docs/reference/resource/query.md +11 -1
  16. data/docs/reference/ui/displays.md +19 -2
  17. data/docs/reference/ui/forms.md +54 -4
  18. data/gemfiles/rails_7.gemfile +1 -0
  19. data/gemfiles/rails_7.gemfile.lock +13 -3
  20. data/gemfiles/rails_8.0.gemfile +1 -0
  21. data/gemfiles/rails_8.0.gemfile.lock +10 -1
  22. data/gemfiles/rails_8.1.gemfile +1 -0
  23. data/gemfiles/rails_8.1.gemfile.lock +10 -1
  24. data/lib/plutonium/auth/rodauth.rb +7 -0
  25. data/lib/plutonium/core/controller.rb +0 -2
  26. data/lib/plutonium/helpers/content_helper.rb +0 -3
  27. data/lib/plutonium/query/filter.rb +2 -1
  28. data/lib/plutonium/query/filters/tags.rb +36 -0
  29. data/lib/plutonium/ui/component/resolves_tags.rb +13 -0
  30. data/lib/plutonium/ui/display/base.rb +50 -2
  31. data/lib/plutonium/ui/display/components/badge.rb +1 -1
  32. data/lib/plutonium/ui/display/components/binary.rb +20 -0
  33. data/lib/plutonium/ui/display/components/boolean.rb +1 -1
  34. data/lib/plutonium/ui/display/components/code.rb +23 -0
  35. data/lib/plutonium/ui/display/components/color.rb +14 -6
  36. data/lib/plutonium/ui/display/components/duration.rb +31 -0
  37. data/lib/plutonium/ui/display/components/file_size.rb +22 -0
  38. data/lib/plutonium/ui/display/components/key_value.rb +37 -0
  39. data/lib/plutonium/ui/display/components/list.rb +33 -0
  40. data/lib/plutonium/ui/display/components/number.rb +32 -0
  41. data/lib/plutonium/ui/display/components/progress.rb +36 -0
  42. data/lib/plutonium/ui/display/components/rating.rb +36 -0
  43. data/lib/plutonium/ui/display/components/relative_time.rb +25 -0
  44. data/lib/plutonium/ui/display/components/rich_text.rb +23 -0
  45. data/lib/plutonium/ui/display/components/tags.rb +18 -0
  46. data/lib/plutonium/ui/display/options/inferred_types.rb +13 -0
  47. data/lib/plutonium/ui/display/theme.rb +24 -2
  48. data/lib/plutonium/ui/form/base.rb +30 -1
  49. data/lib/plutonium/ui/form/components/binary.rb +40 -0
  50. data/lib/plutonium/ui/form/components/key_value_store.rb +22 -71
  51. data/lib/plutonium/ui/form/components/list.rb +46 -0
  52. data/lib/plutonium/ui/form/components/rating.rb +51 -0
  53. data/lib/plutonium/ui/form/components/rich_text.rb +20 -0
  54. data/lib/plutonium/ui/form/components/tags.rb +45 -0
  55. data/lib/plutonium/ui/form/components/tags_filter.rb +40 -0
  56. data/lib/plutonium/ui/form/components/uppy.rb +1 -1
  57. data/lib/plutonium/ui/form/options/inferred_types.rb +16 -15
  58. data/lib/plutonium/ui/form/theme.rb +20 -4
  59. data/lib/plutonium/ui/grid/card.rb +13 -1
  60. data/lib/plutonium/ui/layout/base.rb +10 -1
  61. data/lib/plutonium/ui/options/rich_text_field.rb +18 -0
  62. data/lib/plutonium/ui/options/secret_field.rb +32 -0
  63. data/lib/plutonium/ui/options/tags_field.rb +23 -0
  64. data/lib/plutonium/ui/table/components/toolbar.rb +2 -2
  65. data/lib/plutonium/ui/table/components/view_switcher.rb +4 -2
  66. data/lib/plutonium/ui/table/display_theme.rb +30 -2
  67. data/lib/plutonium/version.rb +1 -1
  68. data/package.json +1 -1
  69. data/src/css/components.css +43 -0
  70. data/src/css/flatpickr.css +91 -0
  71. data/src/js/controllers/easymde_controller.js +1 -0
  72. data/src/js/controllers/register_controllers.js +2 -0
  73. data/src/js/controllers/slim_select_controller.js +32 -3
  74. data/src/js/controllers/timeago_controller.js +40 -0
  75. metadata +25 -2
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ module Components
7
+ # An array value edited as chips: a multi-select (slim-select) whose
8
+ # options are the current values plus any `suggestions:`.
9
+ #
10
+ # addable: false only suggestions can be picked (default: values
11
+ # can be typed in)
12
+ # limit: 5 caps the number of chips
13
+ class List < Phlexi::Form::Components::Select
14
+ protected
15
+
16
+ def build_attributes
17
+ @suggestions = Array(attributes.delete(:suggestions))
18
+ @limit = attributes.delete(:limit)
19
+ @addable = attributes.delete(:addable) != false
20
+
21
+ attributes[:multiple] = true
22
+ attributes[:choices] ||= (current_values + @suggestions.map(&:to_s)).uniq
23
+ attributes[:data_slim_select_addable_value] = "true" if @addable
24
+ attributes[:data_slim_select_max_selected_value] = @limit if @limit
25
+ super
26
+ end
27
+
28
+ def current_values = Array(field.value).map(&:to_s)
29
+
30
+ def selected?(option) = current_values.include?(option.to_s)
31
+
32
+ # Selecting nothing submits the hidden "" alone, which clears the list.
33
+ def normalize_input(input_value)
34
+ super&.reject(&:blank?)
35
+ end
36
+
37
+ # Typed-in values aren't among the rendered choices, so only a
38
+ # non-addable list restricts the submission to them.
39
+ def normalize_simple_input(input_value)
40
+ @addable ? input_value&.to_s : super
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ module Components
7
+ # A star picker: one radio per star, no JS. The radios run from `max:`
8
+ # (default 5) down to 1 in a row-reversed flex box, so `.pu-rating`'s
9
+ # `:checked ~ label` and `:hover ~ label` rules fill the stars up to
10
+ # the chosen or hovered one. An optional rating ends with a "Clear"
11
+ # radio (blank value); it comes last so no fill rule reaches it.
12
+ class Rating < Phlexi::Form::Components::Base
13
+ include Phlexi::Form::Components::Concerns::HandlesInput
14
+
15
+ def view_template
16
+ fieldset(id: attributes[:id], class: tokens("pu-rating", attributes[:class]), role: :radiogroup) do
17
+ @max.downto(1) do |score|
18
+ id = "#{attributes[:id]}_#{score}"
19
+ input(type: :radio, id:, name: attributes[:name], value: score, checked: field.value.to_i == score,
20
+ required: attributes[:required], disabled: attributes[:disabled], class: "sr-only")
21
+ label(for: id, title: star_label(score)) do
22
+ render Phlex::TablerIcons::Star.new
23
+ span(class: "sr-only") { star_label(score) }
24
+ end
25
+ end
26
+ render_clear unless attributes[:required]
27
+ end
28
+ end
29
+
30
+ protected
31
+
32
+ def build_attributes
33
+ super
34
+ @max = attributes.delete(:max) || 5
35
+ end
36
+
37
+ private
38
+
39
+ def render_clear
40
+ id = "#{attributes[:id]}_clear"
41
+ input(type: :radio, id:, name: attributes[:name], value: "", checked: field.value.blank?,
42
+ disabled: attributes[:disabled], class: "sr-only")
43
+ label(for: id, class: "pu-rating-clear") { Plutonium::Translation.t("plutonium.ui.form.rating.clear") }
44
+ end
45
+
46
+ def star_label(score) = Plutonium::Translation.t("plutonium.ui.display.rating.label", value: score, max: @max)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ module Components
7
+ # A `has_rich_text` field, rendered through Action Text's own helper:
8
+ # Trix by default, Lexxy once the app adds the lexxy gem (which takes
9
+ # over the helper). The editor's JS comes from the app's bundle.
10
+ class RichText < Phlexi::Form::Components::Base
11
+ include Phlexi::Form::Components::Concerns::HandlesInput
12
+
13
+ def view_template
14
+ raw(safe(helpers.rich_text_area_tag(attributes[:name], field.value, id: attributes[:id], required: attributes[:required])))
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ 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,10 @@ 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
137
153
  })
138
154
  end
139
155
  end
@@ -314,7 +314,19 @@ module Plutonium
314
314
  "#{record.class.name} doesn't respond to it. " \
315
315
  "Define the method on the model or remove the slot."
316
316
  end
317
- record.public_send(name)
317
+ value = record.public_send(name)
318
+ # Slots render raw values, so mask secrets here. Blank stays blank so
319
+ # the slot still collapses.
320
+ (value.present? && secret_slot?(name)) ? Plutonium::UI::Options::SecretField::MASK : value
321
+ end
322
+
323
+ # Same rule as the show page: an explicit `as:` (display over field)
324
+ # decides; otherwise the secret-name heuristic does.
325
+ def secret_slot?(name)
326
+ name = name.to_sym
327
+ declared = resource_definition.defined_displays.dig(name, :options, :as) ||
328
+ resource_definition.defined_fields.dig(name, :options, :as)
329
+ declared ? %i[password secret].include?(declared.to_sym) : Plutonium::UI::Options::SecretField.secret_name?(name)
318
330
  end
319
331
 
320
332
  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
@@ -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.67.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.67.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",
@@ -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
+ }