lcp 0.1.0 → 0.2.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 (109) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/lcp-custom-field/SKILL.md +10 -1
  3. data/.claude/skills/lcp-custom-field/agents/openai.yaml +4 -0
  4. data/.claude/skills/lcp-getting-started/SKILL.md +1 -1
  5. data/.claude/skills/lcp-getting-started/agents/openai.yaml +4 -0
  6. data/.claude/skills/lcp-host-binding/agents/openai.yaml +4 -0
  7. data/.claude/skills/lcp-model/agents/openai.yaml +4 -0
  8. data/.claude/skills/lcp-permissions/agents/openai.yaml +4 -0
  9. data/.claude/skills/lcp-presenter/agents/openai.yaml +4 -0
  10. data/.claude/skills/lcp-workflow/SKILL.md +10 -2
  11. data/.claude/skills/lcp-workflow/agents/openai.yaml +4 -0
  12. data/CHANGELOG.md +59 -1
  13. data/app/assets/javascripts/lcp_ruby/dev_toolbar.js +5 -6
  14. data/app/controllers/concerns/lcp_ruby/zone_resolution.rb +14 -1
  15. data/app/helpers/lcp_ruby/display/card_helper.rb +71 -16
  16. data/app/helpers/lcp_ruby/display_helper.rb +30 -3
  17. data/app/helpers/lcp_ruby/display_template_helper.rb +3 -3
  18. data/app/helpers/lcp_ruby/layout_helper.rb +49 -8
  19. data/app/helpers/lcp_ruby/tree_helper.rb +24 -3
  20. data/app/models/lcp_ruby/user.rb +10 -0
  21. data/app/views/layouts/lcp_ruby/application.html.erb +42 -20
  22. data/app/views/layouts/lcp_ruby/auth.html.erb +6 -1
  23. data/app/views/lcp_ruby/resources/_show_sections.html.erb +24 -17
  24. data/app/views/lcp_ruby/resources/_table_index.html.erb +12 -17
  25. data/app/views/lcp_ruby/widgets/_markdown.html.erb +13 -0
  26. data/app/views/lcp_ruby/widgets/_presenter_zone.html.erb +23 -7
  27. data/app/views/lcp_ruby/widgets/_rich_text.html.erb +25 -0
  28. data/docs/README.md +3 -0
  29. data/docs/feature-catalog.md +5 -2
  30. data/docs/feature-catalog.yml +89 -9
  31. data/docs/getting-started.md +38 -22
  32. data/docs/guides/dashboards.md +44 -1
  33. data/docs/guides/display-types.md +6 -0
  34. data/docs/guides/host-application.md +1 -1
  35. data/docs/guides/windows-setup.md +211 -0
  36. data/docs/guides/workflow.md +1 -1
  37. data/docs/reference/asset-pipeline.md +79 -0
  38. data/docs/reference/pages.md +50 -1
  39. data/docs/reference/presenters.md +6 -0
  40. data/docs/reference/workflow.md +2 -2
  41. data/examples/crm/Gemfile +3 -0
  42. data/examples/crm/Gemfile.lock +14 -9
  43. data/examples/crm/app/assets/config/manifest.js +4 -0
  44. data/examples/hr/Gemfile +3 -0
  45. data/examples/hr/Gemfile.lock +14 -9
  46. data/examples/hr/app/assets/config/manifest.js +4 -0
  47. data/examples/showcase/Gemfile +3 -0
  48. data/examples/showcase/Gemfile.lock +12 -9
  49. data/examples/showcase/app/assets/config/manifest.js +4 -0
  50. data/examples/showcase/config/lcp_ruby/pages/main_dashboard.yml +17 -2
  51. data/examples/showcase/config/locales/cs.yml +8 -0
  52. data/examples/showcase/config/locales/en.yml +8 -0
  53. data/examples/todo/Gemfile +3 -0
  54. data/examples/todo/Gemfile.lock +14 -9
  55. data/examples/todo/app/assets/config/manifest.js +4 -0
  56. data/exe/lcp +1 -1
  57. data/lib/generators/lcp_ruby/agent_setup_generator.rb +12 -9
  58. data/lib/generators/lcp_ruby/claude_skills_generator.rb +23 -19
  59. data/lib/generators/lcp_ruby/install_generator.rb +171 -3
  60. data/lib/generators/lcp_ruby/templates/agent_setup/agents_md.md +2 -1
  61. data/lib/generators/lcp_ruby/templates/agent_setup/claude_md.md +3 -2
  62. data/lib/generators/lcp_ruby/templates/install/menu.yml.tt +4 -0
  63. data/lib/generators/lcp_ruby/templates/monitoring/page.yml +5 -0
  64. data/lib/lcp_ruby/app_template.rb +37 -1
  65. data/lib/lcp_ruby/array_query.rb +33 -10
  66. data/lib/lcp_ruby/cli/new_command.rb +9 -4
  67. data/lib/lcp_ruby/cli/run_command.rb +98 -11
  68. data/lib/lcp_ruby/cli/skills_command.rb +27 -15
  69. data/lib/lcp_ruby/cli.rb +5 -1
  70. data/lib/lcp_ruby/configuration.rb +13 -2
  71. data/lib/lcp_ruby/custom_fields/applicator.rb +8 -0
  72. data/lib/lcp_ruby/custom_fields/query.rb +28 -20
  73. data/lib/lcp_ruby/display/markdown_sanitize.rb +36 -0
  74. data/lib/lcp_ruby/display/renderers/markdown.rb +6 -13
  75. data/lib/lcp_ruby/engine.rb +185 -1
  76. data/lib/lcp_ruby/export/data_generator.rb +5 -1
  77. data/lib/lcp_ruby/export/value_formatter.rb +34 -0
  78. data/lib/lcp_ruby/grouped_query/builder.rb +21 -0
  79. data/lib/lcp_ruby/metadata/configuration_validator.rb +5 -5
  80. data/lib/lcp_ruby/metadata/field_path.rb +57 -0
  81. data/lib/lcp_ruby/metadata/loader.rb +33 -1
  82. data/lib/lcp_ruby/metadata/menu_definition.rb +32 -2
  83. data/lib/lcp_ruby/metadata/menu_item.rb +53 -4
  84. data/lib/lcp_ruby/metadata/model_definition.rb +9 -0
  85. data/lib/lcp_ruby/metadata/zone_definition.rb +12 -5
  86. data/lib/lcp_ruby/metrics/json_query.rb +5 -0
  87. data/lib/lcp_ruby/model_factory/builder.rb +5 -0
  88. data/lib/lcp_ruby/model_factory/json_type_applicator.rb +35 -0
  89. data/lib/lcp_ruby/model_factory/label_method_builder.rb +3 -19
  90. data/lib/lcp_ruby/model_factory/schema_manager.rb +19 -0
  91. data/lib/lcp_ruby/pages/definition_validator.rb +13 -0
  92. data/lib/lcp_ruby/presenter/field_value_resolver.rb +36 -19
  93. data/lib/lcp_ruby/schemas/page.json +33 -1
  94. data/lib/lcp_ruby/search/custom_field_filter.rb +5 -0
  95. data/lib/lcp_ruby/skills_installer.rb +15 -2
  96. data/lib/lcp_ruby/tasks/doctor.rb +13 -8
  97. data/lib/lcp_ruby/version.rb +1 -1
  98. data/lib/lcp_ruby/widgets/data_resolver.rb +42 -1
  99. data/lib/lcp_ruby/widgets/date_grouper.rb +19 -0
  100. data/lib/lcp_ruby/workflow/contract_validator.rb +1 -1
  101. data/lib/lcp_ruby/workflow/model_source.rb +2 -2
  102. data/lib/lcp_ruby.rb +18 -0
  103. data/lib/tasks/lcp_ruby_db.rake +8 -1
  104. data/lib/tasks/lcp_ruby_i18n_check.rake +8 -0
  105. metadata +34 -24
  106. data/examples/crm/erd.md +0 -163
  107. data/examples/hr/erd.md +0 -396
  108. data/examples/showcase/erd.md +0 -567
  109. data/examples/todo/erd.md +0 -21
@@ -25,7 +25,18 @@
25
25
  h.setAttribute("data-lcp-radius", pick("lcp-radius", "<%= radius_def %>"));
26
26
  })();
27
27
  </script>
28
- <% if Rails.application.config.respond_to?(:assets) %>
28
+ <%# Engine asset tags. `lcp_emit_engine_assets?` is false when the host
29
+ opted out via `config.skip_asset_pipeline_check = true` — that host runs
30
+ its own JS bundler (esbuild / importmap / jsbundling) and owns including
31
+ the LCP bundle, so the engine must NOT emit these pipeline-dependent tags
32
+ (Propshaft would raise MissingAssetError → 500 on every render). When NOT
33
+ opted out, the `lcp_ruby.asset_pipeline_check` boot initializer has
34
+ already guaranteed Sprockets is wired (audit #16), so the tags are safe.
35
+ The i18n payload is an inline <script> with no pipeline dependency and is
36
+ always emitted so a host-bundled LCP app.js can still consume it. The
37
+ dev-toolbar and vendored-mermaid blocks below use the same gate. %>
38
+ <%= lcp_i18n_payload_tag %>
39
+ <% if lcp_emit_engine_assets? %>
29
40
  <%= stylesheet_link_tag "lcp_ruby/tom-select", media: "all" %>
30
41
  <%= stylesheet_link_tag "lcp_ruby/application", media: "all" %>
31
42
  <script type="module" src="<%= asset_path('turbo.min.js') %>" data-turbo-track="reload"></script>
@@ -33,25 +44,32 @@
33
44
  <%= javascript_include_tag "lcp_ruby/activestorage.min", defer: true, "data-turbo-track": "reload" %>
34
45
  <%= javascript_include_tag "lcp_ruby/lucide.min", defer: true, "data-turbo-track": "reload" %>
35
46
  <%= javascript_include_tag "lcp_ruby/lucide_init", defer: true, "data-turbo-track": "reload" %>
36
- <%= lcp_i18n_payload_tag %>
37
47
  <%= javascript_include_tag "lcp_ruby/application", defer: true, "data-turbo-track": "reload" %>
38
- <%# FOUC mask for responsive top nav (Phase 1). The `data-measuring`
39
- attribute hides the nav while JS runs initial overflow measurement;
40
- the noscript override ensures the nav remains visible when JS is
41
- disabled or fails to load. %>
42
- <noscript><style>nav[data-measuring]{visibility:visible !important;}</style></noscript>
43
- <% if defined?(Chartkick) && Rails.application.assets&.find_asset("Chart.bundle") %>
44
- <%= javascript_include_tag "Chart.bundle", defer: true, "data-turbo-track": "reload" %>
45
- <%= javascript_include_tag "chartkick", defer: true, "data-turbo-track": "reload" %>
46
- <% end %>
47
- <% if LcpRuby::Workflow::Registry.available? %>
48
- <% mermaid_src = case LcpRuby.configuration.mermaid_source
49
- when :cdn then "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"
50
- when :vendored then asset_path("lcp_ruby/mermaid.min.js")
51
- else LcpRuby.configuration.mermaid_source.to_s
52
- end %>
53
- <script src="<%= mermaid_src %>" defer data-turbo-track="reload"></script>
54
- <% end %>
48
+ <% end %>
49
+ <%# FOUC mask for responsive top nav (Phase 1). The `data-measuring`
50
+ attribute hides the nav while JS runs initial overflow measurement;
51
+ the noscript override ensures the nav remains visible when JS is
52
+ disabled or fails to load. %>
53
+ <noscript><style>nav[data-measuring]{visibility:visible !important;}</style></noscript>
54
+ <%# Gated on lcp_emit_engine_assets? first: on a Propshaft opt-out host
55
+ `Rails.application.assets` is a non-nil Propshaft::Assembly with no
56
+ `find_asset`, so the `&.` would NOT short-circuit and would raise
57
+ NoMethodError 500. The opt-out host owns its own Chartkick anyway. %>
58
+ <% if lcp_emit_engine_assets? && defined?(Chartkick) && Rails.application.assets&.find_asset("Chart.bundle") %>
59
+ <%= javascript_include_tag "Chart.bundle", defer: true, "data-turbo-track": "reload" %>
60
+ <%= javascript_include_tag "chartkick", defer: true, "data-turbo-track": "reload" %>
61
+ <% end %>
62
+ <%# Mermaid (workflow graphs). Gated on lcp_emit_engine_assets? too: the
63
+ :vendored branch resolves asset_path("lcp_ruby/mermaid.min.js"), which
64
+ would 500 on a Propshaft opt-out host — and an opt-out host owns its own
65
+ mermaid anyway. %>
66
+ <% if LcpRuby::Workflow::Registry.available? && lcp_emit_engine_assets? %>
67
+ <% mermaid_src = case LcpRuby.configuration.mermaid_source
68
+ when :cdn then "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"
69
+ when :vendored then asset_path("lcp_ruby/mermaid.min.js")
70
+ else LcpRuby.configuration.mermaid_source.to_s
71
+ end %>
72
+ <script src="<%= mermaid_src %>" defer data-turbo-track="reload"></script>
55
73
  <% end %>
56
74
  </head>
57
75
  <body class="lcp-layout-<%= menu_layout %>"
@@ -157,7 +175,11 @@
157
175
  </div>
158
176
  </dialog>
159
177
 
160
- <% if LcpRuby::DevToolbar.enabled? %>
178
+ <%# Dev toolbar. Whole block (incl. the metadata <meta>) gated on
179
+ lcp_emit_engine_assets? — the meta URL is consumed by the pipeline-loaded
180
+ dev_toolbar.js, so emitting it without the JS on an opt-out Propshaft host
181
+ would 500 on the asset tags and otherwise advertise a dead endpoint. %>
182
+ <% if LcpRuby::DevToolbar.enabled? && lcp_emit_engine_assets? %>
161
183
  <meta name="lcp-dev-metadata-url" content="<%= lcp_ruby.dev_toolbar_metadata_path %>">
162
184
  <%= stylesheet_link_tag "lcp_ruby/highlight-github.min", media: "all" %>
163
185
  <%= stylesheet_link_tag "lcp_ruby/dev_toolbar", media: "all" %>
@@ -15,7 +15,12 @@
15
15
  h.setAttribute("data-lcp-radius", localStorage.getItem("lcp-radius") || "rounded");
16
16
  })();
17
17
  </script>
18
- <% if Rails.application.config.respond_to?(:assets) %>
18
+ <%# Same opt-out gate as the main layout (lcp_emit_engine_assets?): a host
19
+ that set skip_asset_pipeline_check bundles LCP itself, so emitting the
20
+ pipeline-dependent engine stylesheet here would 500 the login pages on
21
+ Propshaft. The old `respond_to?(:assets)` gate was true on Propshaft too,
22
+ so it never actually suppressed the tag. %>
23
+ <% if lcp_emit_engine_assets? %>
19
24
  <%= stylesheet_link_tag "lcp_ruby/application", media: "all" %>
20
25
  <% end %>
21
26
  <style>
@@ -65,17 +65,12 @@
65
65
  id="field-<%= field_name.to_s.tr('.', '-') %>"
66
66
  <%= "style=\"grid-column: span #{field_config['col_span']}\"" if field_config["col_span"] %>>
67
67
  <% field_def = field_config["field_definition"]
68
- if field_name.include?(".")
69
- _parts = field_name.split(".", 2)
70
- _assoc = current_model_definition&.associations&.find { |a| a.name == _parts[0] }
71
- _label_model = _assoc&.target_model || current_model_name
72
- if field_def.nil? && _assoc
73
- _target_def = LcpRuby.loader.model_definitions[_assoc.target_model]
74
- field_def = _target_def&.field(_parts[1])
75
- end
76
- else
77
- _label_model = current_model_name
78
- end %>
68
+ # Walk the dot-path through metadata to find the terminal field
69
+ # def + owning model name. FieldPath handles any depth; falls
70
+ # back to current_model_name for non-LCP / unresolvable paths.
71
+ _resolved_fd, _resolved_model = LcpRuby::Metadata::FieldPath.terminal(current_model_definition, field_name, through_collections: true)
72
+ field_def ||= _resolved_fd
73
+ _label_model = _resolved_model || current_model_name %>
79
74
  <label><%= field_label_for(field_config.merge("field" => field_name), field_def: field_def, model_name: _label_model) %></label>
80
75
  <div class="lcp-field-value">
81
76
  <%
@@ -88,31 +83,43 @@
88
83
  # Spec § Phase 4: "Show and card pick up the fix automatically
89
84
  # through the runtime registry fallback."
90
85
  effective_renderer_key = field_config["renderer"].presence
91
- if effective_renderer_key.blank? && field_def && LcpRuby.configuration.runtime_type_renderers
86
+ # Skip the type-name fallback for Array values (has_many enum
87
+ # dot-paths): a scalar type renderer (e.g. enum → badge) can't
88
+ # render an Array, and it would shadow the collection branch
89
+ # below, emitting the Ruby array literal (issue #18 / finding E).
90
+ if effective_renderer_key.blank? && field_def && LcpRuby.configuration.runtime_type_renderers && !value.is_a?(Array)
92
91
  effective_renderer_key = field_def.type.to_s
93
92
  end
94
93
 
95
94
  rendered_value = if field_config["partial"]
96
95
  render partial: field_config["partial"], locals: { value: value, record: record, options: field_config["options"] || {} }
97
96
  elsif effective_renderer_key && LcpRuby::Display::RendererRegistry.renderer_for(effective_renderer_key)
98
- render_display_value(value, effective_renderer_key, field_config["options"], field_def, record: record)
97
+ render_display_value(value, effective_renderer_key, field_config["options"], field_def, record: record, model_name: _label_model)
99
98
  elsif value.is_a?(Array)
100
- render_display_value(value, "collection", {})
99
+ # Pass field_def + model so enum elements humanize (issue #18)
100
+ # the collection renderer itself only does item.to_s.
101
+ render_display_value(value, "collection", {}, field_def, record: record, model_name: _label_model)
101
102
  elsif value.is_a?(ActiveRecord::Base)
102
103
  empty_value_placeholder(display_association_value(value), current_presenter)
103
104
  else
104
- display_val = format_enum_display(value, field_def, current_model_name)
105
+ display_val = format_enum_display(value, field_def, _label_model)
105
106
  empty_value_placeholder(display_val, current_presenter)
106
107
  end
107
108
  %>
108
109
  <%= wrap_link_through(value, rendered_value, record, field_config) %>
109
110
  <% if field_config["copyable"] && value.present? %>
110
111
  <% copy_value = if value.is_a?(Array)
111
- value.join(", ")
112
+ # Humanize enum elements for copy (issue #18) so the
113
+ # clipboard gets "Active, Done" instead of "active, done".
114
+ format_enum_display(value, field_def, _label_model).join(", ")
112
115
  elsif value.is_a?(ActiveRecord::Base)
113
116
  strip_tags(display_association_value(value).to_s)
114
117
  else
115
- strip_tags(value.to_s)
118
+ # Enum scalars resolve to raw keys (issue #18); humanize for copy
119
+ # so the clipboard gets "Active" instead of "active".
120
+ # (Scalar only — has_many enum dot-paths are validator-blocked
121
+ # in show sections, so the Array branch above is non-enum.)
122
+ strip_tags(format_enum_display(value, field_def, _label_model).to_s)
116
123
  end %>
117
124
  <% copy_label = I18n.t('lcp_ruby.toolbar.copy_value', default: 'Copy value') %>
118
125
  <button type="button" class="lcp-copy-field" data-lcp-copy-value="<%= copy_value %>" title="<%= copy_label %>" aria-label="<%= copy_label %>">
@@ -9,20 +9,12 @@
9
9
  columns.each do |col|
10
10
  field_path = col["field"]
11
11
  next unless field_path
12
- if field_path.include?(".")
13
- parts = field_path.split(".", 2)
14
- assoc = current_model_definition&.associations&.find { |a| a.name == parts[0] }
15
- if assoc
16
- target_def = LcpRuby.loader.model_definitions[assoc.target_model]
17
- if target_def
18
- col_field_defs[field_path] = target_def.field(parts[1])
19
- col_model_names[field_path] = assoc.target_model
20
- end
21
- end
22
- else
23
- col_field_defs[field_path] = current_model_definition&.field(field_path)
24
- col_model_names[field_path] = current_model_name
25
- end
12
+ # Metadata::FieldPath walks any-depth dot-paths; nil/nil for non-LCP
13
+ # or unresolvable segments. Pre-A2 humanization in the resolver masked
14
+ # 3+ part path failures here now this is the single lookup point.
15
+ fd, model_name = LcpRuby::Metadata::FieldPath.terminal(current_model_definition, field_path, through_collections: true)
16
+ col_field_defs[field_path] = fd
17
+ col_model_names[field_path] = model_name || current_model_name
26
18
  end %>
27
19
  <% reorderable = current_presenter.reorderable? &&
28
20
  current_model_definition.positioned? &&
@@ -105,17 +97,20 @@
105
97
  <td class="<%= hidden_on_classes(col) %> <%= 'lcp-pinned-left' if col['pinned'] == 'left' %>">
106
98
  <% value = @field_resolver.resolve(record, col["field"], fk_map: @fk_map) %>
107
99
  <% fd = col_field_defs[col["field"]] %>
100
+ <% fd_model = col_model_names[col["field"]] || current_model_name %>
108
101
  <%
109
102
  rendered_value = if col["partial"]
110
103
  render partial: col["partial"], locals: { value: value, record: record, options: col["options"] || {} }
111
104
  elsif col["renderer"]
112
- render_display_value(value, col["renderer"], col["options"], fd, record: record)
105
+ render_display_value(value, col["renderer"], col["options"], fd, record: record, model_name: fd_model)
113
106
  elsif value.is_a?(Array)
114
- render_display_value(value, "collection", {})
107
+ # Pass fd + model so enum elements humanize (issue #18) — the
108
+ # collection renderer itself only does item.to_s.
109
+ render_display_value(value, "collection", {}, fd, record: record, model_name: fd_model)
115
110
  elsif value.is_a?(ActiveRecord::Base)
116
111
  empty_value_placeholder(display_association_value(value), current_presenter)
117
112
  else
118
- display_val = format_enum_display(value, fd, col_model_names[col["field"]] || current_model_name)
113
+ display_val = format_enum_display(value, fd, fd_model)
119
114
  empty_value_placeholder(display_val, current_presenter)
120
115
  end
121
116
  %>
@@ -0,0 +1,13 @@
1
+ <%#
2
+ Markdown widget — i18n-resolved source is converted to HTML in the
3
+ resolver (Commonmarker without `unsafe:` so raw HTML in the source
4
+ is stripped at parse time), then sanitized here against the shared
5
+ allow-list in `LcpRuby::Display::MarkdownSanitize` — same list the
6
+ model-level :markdown renderer uses, so on-page and in-table
7
+ markdown render identically.
8
+ %>
9
+ <div class="lcp-text-content lcp-markdown">
10
+ <%= sanitize(data[:html],
11
+ tags: LcpRuby::Display::MarkdownSanitize::TAGS,
12
+ attributes: LcpRuby::Display::MarkdownSanitize::ATTRIBUTES) %>
13
+ </div>
@@ -11,16 +11,26 @@
11
11
  <%
12
12
  zone_model_def = data[:model_definition]
13
13
  zone_model_name = zone_model_def&.name
14
- # Index field_definitions by name to avoid O(cols × fields) linear scans
15
- # in the header loop.
16
- zone_field_defs = (zone_model_def&.fields || []).index_by(&:name)
14
+ # Resolve every column's terminal field def + owning model name via
15
+ # Metadata::FieldPath so dot-path enum columns (e.g. "contact.status")
16
+ # humanize against the correct i18n namespace. Direct fields resolve
17
+ # via the same walker (single-segment path).
18
+ zone_field_defs = {}
19
+ zone_field_models = {}
20
+ data[:column_set].visible_table_columns.each do |col|
21
+ path = col["field"]
22
+ next unless path
23
+ fd, mn = LcpRuby::Metadata::FieldPath.terminal(zone_model_def, path, through_collections: true)
24
+ zone_field_defs[path] = fd
25
+ zone_field_models[path] = mn || zone_model_name
26
+ end
17
27
  %>
18
28
  <table class="lcp-table lcp-table-compact">
19
29
  <thead>
20
30
  <tr>
21
31
  <% data[:column_set].visible_table_columns.each do |col| %>
22
32
  <th>
23
- <% label = field_label_for(col, field_def: zone_field_defs[col["field"]], model_name: zone_model_name) %>
33
+ <% label = field_label_for(col, field_def: zone_field_defs[col["field"]], model_name: zone_field_models[col["field"]] || zone_model_name) %>
24
34
  <% if col["sortable"] %>
25
35
  <%
26
36
  field = col["field"]
@@ -60,15 +70,21 @@
60
70
  <% data[:column_set].visible_table_columns.each do |col| %>
61
71
  <td>
62
72
  <% value = data[:field_value_resolver].resolve(record, col["field"]) %>
73
+ <% fd = zone_field_defs[col["field"]] %>
74
+ <% fd_model = zone_field_models[col["field"]] %>
63
75
  <%
64
76
  rendered_value = if col["renderer"]
65
- render_display_value(value, col["renderer"], col["options"], nil, record: record)
77
+ render_display_value(value, col["renderer"], col["options"], fd, record: record, model_name: fd_model)
66
78
  elsif value.is_a?(Array)
67
- render_display_value(value, "collection", {})
79
+ # Pass fd + model so enum elements humanize (issue #18) — the
80
+ # collection renderer itself only does item.to_s.
81
+ render_display_value(value, "collection", {}, fd, record: record, model_name: fd_model)
68
82
  elsif value.is_a?(ActiveRecord::Base)
69
83
  empty_value_placeholder(display_association_value(value))
70
84
  else
71
- empty_value_placeholder(value)
85
+ # Resolver returns raw enum keys (issue #18); humanize via
86
+ # the field's owning model i18n namespace.
87
+ empty_value_placeholder(format_enum_display(value, fd, fd_model))
72
88
  end
73
89
  %>
74
90
  <%= wrap_link_through(value, rendered_value, record, col) %>
@@ -0,0 +1,25 @@
1
+ <%#
2
+ Rich text widget — renders the i18n-resolved content as HTML.
3
+
4
+ Trust model (defense-in-depth):
5
+ * Trusted page (YAML/auto-defined, code-reviewed locale source) →
6
+ raw pass-through, same posture as any engine view that uses `raw`.
7
+ * Untrusted page (database-defined, end-user-editable through the
8
+ platform UI) → sanitized against the shared MarkdownSanitize allow-list.
9
+ `data[:trusted]` is set by Widgets::DataResolver from the page origin;
10
+ this branch is fail-closed (absent/false → sanitize), so even a
11
+ rich_text widget that slipped past the save-time Pages::DefinitionValidator
12
+ (seeds, update_column, pre-existing records) can't emit unsanitized HTML.
13
+
14
+ When the source is author-written markdown, prefer the :markdown widget —
15
+ friendlier authoring and the same sanitize guarantee.
16
+ %>
17
+ <div class="lcp-text-content lcp-text-content--rich">
18
+ <% if data[:trusted] %>
19
+ <%= raw(data[:content]) %>
20
+ <% else %>
21
+ <%= sanitize(data[:content],
22
+ tags: LcpRuby::Display::MarkdownSanitize::TAGS,
23
+ attributes: LcpRuby::Display::MarkdownSanitize::ATTRIBUTES) %>
24
+ <% end %>
25
+ </div>
data/docs/README.md CHANGED
@@ -9,6 +9,7 @@ LCP Ruby is a Rails mountable engine that generates full CRUD information system
9
9
  - **`rails generate lcp_ruby:install`** — add LCP to an existing Rails app
10
10
  - **`rails generate lcp_ruby:entity NAME field:type ...`** — scaffold a complete CRUD entity (model + presenter + permissions + view group). See [Getting Started → Scaffolding new entities](getting-started.md#scaffolding-new-entities) and the [design spec](design/entity_generator.md) for the field grammar and worked examples.
11
11
  - [Host Application Guide](guides/host-application.md) — Full app bootstrap, optional dependencies (XLSX, charts, metrics), feature generators
12
+ - [Windows Setup](guides/windows-setup.md) — Native-compile toolchain on a clean Windows box (rbenv-for-windows, MSYS2/GCC gotchas, libyaml, the stale `libwinpthread` fix), the seven-step quick path
12
13
 
13
14
  ## YAML Reference
14
15
 
@@ -49,6 +50,7 @@ Complete attribute reference for every YAML configuration file:
49
50
  - [Import](reference/import.md) — Data import: CSV/XLSX upload, column mapping, strategies, nested has_one, profiles, background execution, generator
50
51
  - [Theme Variables](reference/theme-variables.md) — All `--lcp-*` CSS custom properties with light/dark values
51
52
  - [Engine Configuration](reference/engine-configuration.md) — `LcpRuby.configure` options
53
+ - [Asset Pipeline](reference/asset-pipeline.md) — Sprockets compatibility shim on Rails 8 / Propshaft, boot-time `AssetPipelineError` check, `--skip-asset-pipeline` opt-out, Phase 2 ESM roadmap
52
54
  - [Boot / Reload Lifecycle](reference/boot_lifecycle.md) — Engine boot phases, extension hook timing (`on_model_ready`, `on_models_loaded`), dev-mode metadata watcher, `lcp_ruby:ensure_tables` rake + `db:prepare` / `db:setup` / `db:reset` integration, `boot.lcp_ruby` and `reload.lcp_ruby` AS::Notifications events
53
55
  - [`lcp_ruby:doctor`](reference/doctor.md) — Health check rake task: install manifest replay, feature state vs. prereqs, environment leaks (`BUNDLE_GEMFILE`, `.envrc`), seeds hygiene; `FORMAT=text|json|summary` and `EXIT_ON=error|warning`; install manifest format and JSON schema for CI consumption
54
56
 
@@ -136,6 +138,7 @@ Implemented:
136
138
  - [Auditing](design/auditing.md) — Native change tracking and audit trail
137
139
  - [Tree Structures](design/tree_structures.md) — Declarative parent-child hierarchies with traversal, cycle detection, and tree index view
138
140
  - [Saved Filters & Parameterized Scopes](design/saved_filters.md) — User-persistent named filters with visibility levels, parameterized scopes with typed parameters, generator, CRUD API
141
+ - [Unify Scope Filters onto Presenters](design/grouped_predefined_filters.md) — Surface the shipped page `scope_filters` mechanism (grouped, multi-widget, AND across groups) on plain presenter indexes; reframe `predefined_filters` as sugar over it; fix the saved-filter dead-click; future: retire sugar, multi-select, parameterized options, DB/host sources
139
142
  - [Aggregate Columns](design/aggregate_columns.md) — Virtual computed columns (COUNT, SUM, MIN, MAX, AVG) from associated records, custom SQL, service-based aggregates
140
143
  - [Virtual Columns](design/virtual_columns.md) — Generalized query extensions: expressions, JOINs, GROUP BY, auto-include, backward-compatible with aggregates
141
144
  - [Tiles View](design/tiles_view.md) — Card grid layout for index pages with sort dropdown, per-page selector, and summary bar
@@ -3,7 +3,7 @@
3
3
  Auto-generated from `docs/feature-catalog.yml`. Do not edit manually.
4
4
  Regenerate: `cd examples/showcase && bundle exec rake lcp_ruby:feature_catalog`
5
5
 
6
- 621 features across 45 categories.
6
+ 624 features across 45 categories.
7
7
 
8
8
  ## API Backed Models
9
9
 
@@ -218,13 +218,15 @@ Regenerate: `cd examples/showcase && bundle exec rake lcp_ruby:feature_catalog`
218
218
  - **KPI Trend Indicator** — KPI with delta arrow (compare_scope: on kpi_card). Shows percentage change vs comparison scope. Up/down/neutral. · `docs/reference/pages.md#kpi_card` `docs/guides/dashboards.md#kpi-trend-indicators` `docs/design/dashboards.md#widget-zone-types` `lib/lcp_ruby/widgets/data_resolver.rb`
219
219
  - **Landing Page Configuration** — Post-login redirect (config.landing_page = { role => slug }). Per-role landing pages with default fallback. · `docs/reference/pages.md#page-attributes` `docs/guides/dashboards.md#3-configure-landing-page-optional` `docs/design/dashboards.md#dashboard-as-landing-page` `app/helpers/lcp_ruby/dashboard_helper.rb` `app/views/lcp_ruby/resources/_grid_page.html.erb`
220
220
  - **List Widget** — Compact record list (widget type: list). model:, limit:, link_to:. Shows label_method values in a list. · `docs/reference/pages.md#list` `docs/guides/dashboards.md#list` `docs/design/dashboards.md#widget-zone-types` `lib/lcp_ruby/widgets/data_resolver.rb`
221
+ - **Markdown Widget** — i18n source parsed by Commonmarker, output sanitized (widget type: markdown, content_key:). Raw HTML in source is stripped (`unsafe:` deliberately omitted). · `docs/reference/pages.md#markdown` `docs/guides/dashboards.md#markdown` `lib/lcp_ruby/widgets/data_resolver.rb` `app/views/lcp_ruby/widgets/_markdown.html.erb` `lib/lcp_ruby/display/renderers/markdown.rb`
221
222
  - **Multi-Select Filter** — input_options.multi: true on filter form fields. URL emits bracket-array (?page_filter[field][]=…). Defensive Type::ArrayOf coercion. · `docs/reference/page_filters.md#multi-select-multi-true` `docs/reference/virtual_forms.md#security-invariants` `docs/design/page_filters_as_virtual_forms.md` `lib/lcp_ruby/virtual_fields/types/array_of.rb` `lib/lcp_ruby/pages/filter_form.rb`
222
223
  - **Multi-Series Chart** — Multiple data series in one chart (series: array). Each series has its own name, color, scope, and may override group_by/aggregate. · `docs/reference/pages.md#chart` `docs/design/chart_widget_multi_series.md` `lib/lcp_ruby/widgets/data_resolver.rb`
223
224
  - **Page Filter Form** — Page-level filter bar (filter_form: array, form-syntax fields). Propagated to all zones via ScopeApplicator. Supports text, select, multi-select, association_select, date_range, boolean, … · `docs/reference/page_filters.md` `docs/reference/pages.md#page-filters` `docs/design/page_filters_as_virtual_forms.md` `lib/lcp_ruby/pages/filter_form.rb` `lib/lcp_ruby/pages/filter_form_validator.rb` `lib/lcp_ruby/widgets/scope_applicator.rb` `app/views/lcp_ruby/resources/_filter_form.html.erb`
224
225
  - **Presenter Zone on Dashboard** — Embedded index table in dashboard grid (presenter:, limit:). Reuses existing presenter columns. No widget config. · `docs/reference/pages.md#presenter-zones` `docs/guides/dashboards.md#presenter-zone` `docs/design/dashboards.md#widget-zone-types` `lib/lcp_ruby/widgets/presenter_zone_resolver.rb`
226
+ - **Rich Text Widget** — i18n source emitted as raw HTML (widget type: rich_text, content_key:). Locale carries `<h2>`/`<a>`/`<strong>`; same trust posture as any engine view using `raw`. · `docs/reference/pages.md#rich_text` `docs/guides/dashboards.md#rich-text` `lib/lcp_ruby/widgets/data_resolver.rb` `app/views/lcp_ruby/widgets/_rich_text.html.erb`
225
227
  - **Scope Filters** — Page-level AR-scope toggle bar (scope_filters: array). Each entry maps UI options to named scopes on the page model. widget: select/radio/button_group. · `docs/reference/page_filters.md#scope_filters-syntax` `docs/design/page_filters_as_virtual_forms.md` `lib/lcp_ruby/pages/scope_filter_set.rb` `lib/lcp_ruby/widgets/scope_applicator.rb` `app/views/lcp_ruby/resources/_scope_filters.html.erb`
226
228
  - **Stacked Chart** — Stack series on the Y axis (stacked: true). Legal for column/bar/line/area; hard error for pie/donut. · `docs/reference/pages.md#chart` `docs/design/chart_widget_multi_series.md` `lib/lcp_ruby/widgets/data_resolver.rb`
227
- - **Text Widget** — Static i18n content (widget type: text, content_key:). For welcome messages, announcements on dashboard pages. · `docs/reference/pages.md#text` `docs/guides/dashboards.md#text` `docs/design/dashboards.md#widget-zone-types` `app/helpers/lcp_ruby/dashboard_helper.rb` `app/views/lcp_ruby/resources/_grid_page.html.erb`
229
+ - **Text Widget** — Plain HTML-escaped i18n content (widget type: text, content_key:). For welcome messages and announcements. See rich_text / markdown variants for formatted content. · `docs/reference/pages.md#text` `docs/guides/dashboards.md#text` `docs/design/dashboards.md#widget-zone-types` `lib/lcp_ruby/widgets/data_resolver.rb` `app/views/lcp_ruby/widgets/_text.html.erb`
228
230
 
229
231
  ## Dialogs
230
232
 
@@ -498,6 +500,7 @@ recovery codes, generated credentials). dialog_behavior show_result + result.sty
498
500
  - **Health Check Endpoint** — Built-in /lcp_health JSON endpoint. DB + boot checks. 200/503. Unauthenticated for K8s probes. · `docs/reference/monitoring.md#health-check` `docs/guides/monitoring.md#health-check` `docs/design/monitoring.md#layer-3-health-check-endpoint` `docs/design/monitoring.md#health-check-response` `lib/lcp_ruby/metrics/`
499
501
  - **Monitoring Dashboard (Page)** — Dashboard page (/monitoring). KPI cards from Prometheus metrics + recent errors list widget. · `docs/reference/monitoring.md` `docs/guides/monitoring.md#monitoring-dashboard` `docs/design/monitoring.md#monitoring-dashboard` `docs/design/monitoring.md#layer-5-dashboard-visualization` `lib/lcp_ruby/metrics/` `lib/lcp_ruby/pages/`
500
502
  - **Monitoring Generator** — Single generator (lcp_ruby:monitoring). Creates error log model, presenter, permissions, dashboard page. · `docs/reference/monitoring.md#generator` `docs/design/monitoring.md#generator-output` `lib/lcp_ruby/metrics/`
503
+ - **Page-Level Access Gate** — Runtime enforcement of `page.visible_when:` via PageAuthorization concern + PageGate pure function. Standalone pages with role/service gates return 403 for non-matching users. AUTH-002-runtime / AUTH-003 / AUTH-010 / AUTH-011 codes. Silent-bypass sentinel. · `docs/design/authorization_hardening.md` `docs/reference/invariant_check.md` `docs/design/authorization_hardening.md` `app/controllers/concerns/lcp_ruby/page_authorization.rb` `lib/lcp_ruby/authorization/page_gate.rb` `lib/lcp_ruby/authorization/misconfigured_page_error.rb`
501
504
  - **Prometheus Metrics Endpoint** — /metrics endpoint in Prometheus format. Requires prometheus-client gem. 9 built-in metrics. · `docs/reference/monitoring.md#prometheus-metrics` `docs/guides/monitoring.md#prometheus-grafana` `docs/design/monitoring.md#prometheus-metrics` `docs/design/monitoring.md#layer-2-prometheus-metrics-collection` `lib/lcp_ruby/metrics/`
502
505
  - **Verify Authorized Framework Guarantee** — after_action :verify_authorized on ResourcesController. Future contributor forgets authorize → 500 in dev/test (Pundit::AuthorizationNotPerformedError). Skips 4xx/5xx + Devise. · `docs/design/authorization_hardening.md` `docs/design/authorization_hardening.md` `lib/lcp_ruby/authorized_controller.rb` `lib/lcp_ruby/controller/authorization.rb`
503
506
 
@@ -10214,11 +10214,13 @@ features:
10214
10214
  status: stable
10215
10215
  - name: Text Widget
10216
10216
  category: dashboards
10217
- synopsis: 'Static i18n content (widget type: text, content_key:). For welcome messages,
10218
- announcements on dashboard pages.'
10219
- description: 'A widget zone with `type: text` that renders static i18n content.
10220
- Use `content_key` to reference a locale key. Ideal for welcome messages, announcements,
10221
- or instructions.'
10217
+ synopsis: 'Plain HTML-escaped i18n content (widget type: text, content_key:). For
10218
+ welcome messages and announcements. See rich_text / markdown variants for formatted
10219
+ content.'
10220
+ description: 'A widget zone with `type: text` that renders static i18n content as
10221
+ plain text (Rails default escaping). Use `content_key` to reference a locale key.
10222
+ Ideal for plain announcements; for formatted content use the parallel `rich_text`
10223
+ or `markdown` widget types.'
10222
10224
  config_example: |-
10223
10225
  ```yaml
10224
10226
  - name: welcome_note
@@ -10229,16 +10231,94 @@ features:
10229
10231
  position: { row: 1, col: 10, width: 3 }
10230
10232
  ```
10231
10233
  demo_path: "/showcase/showcase-dashboard"
10232
- demo_hint: The welcome text in the top-right corner of the dashboard is a text widget.
10233
- Its content comes from the `lcp_ruby.dashboard.welcome` i18n key.
10234
+ demo_hint: 'The dashboard demonstrates the formatted variants (markdown for welcome,
10235
+ rich_text for the announcement). For plain text behaviour, use `type: text` instead
10236
+ — the locale string would render escaped.'
10234
10237
  docs_refs:
10235
10238
  - docs/reference/pages.md#text
10236
10239
  - docs/guides/dashboards.md#text
10237
10240
  design_refs:
10238
10241
  - docs/design/dashboards.md#widget-zone-types
10239
10242
  code_refs:
10240
- - app/helpers/lcp_ruby/dashboard_helper.rb
10241
- - app/views/lcp_ruby/resources/_grid_page.html.erb
10243
+ - lib/lcp_ruby/widgets/data_resolver.rb
10244
+ - app/views/lcp_ruby/widgets/_text.html.erb
10245
+ status: stable
10246
+ - name: Rich Text Widget
10247
+ category: dashboards
10248
+ synopsis: 'i18n source emitted as raw HTML (widget type: rich_text, content_key:).
10249
+ Locale carries `<h2>`/`<a>`/`<strong>`; same trust posture as any engine view
10250
+ using `raw`.'
10251
+ description: |-
10252
+ A widget zone with `type: rich_text` that renders the i18n-resolved content as raw HTML — so the translation can contain `<h2>`, `<a>`, `<strong>`, etc. and they render as tags.
10253
+
10254
+ Trust model: locale files are author-controlled, the same trust level as any engine view that uses `raw`. When the translation pipeline involves external translators outside code review, prefer the `markdown` widget instead — it sanitizes Commonmarker output against an explicit tag allow-list.
10255
+ config_example: |-
10256
+ ```yaml
10257
+ - name: announcement
10258
+ type: widget
10259
+ widget:
10260
+ type: rich_text
10261
+ content_key: lcp_ruby.dashboard.announcement_html
10262
+ position: { row: 4, col: 5, width: 8 }
10263
+ ```
10264
+
10265
+ ```yaml
10266
+ # en.yml
10267
+ en:
10268
+ lcp_ruby:
10269
+ dashboard:
10270
+ announcement_html: '<strong>Heads up:</strong> rich_text pipes locale source through raw.'
10271
+ ```
10272
+ demo_path: "/showcase/showcase-dashboard"
10273
+ demo_hint: 'The announcement on row 4 (next to "Draft Articles") uses `type: rich_text`.
10274
+ Inspect the `<strong>` / `<code>` tags — they come straight from the i18n source,
10275
+ no markdown conversion.'
10276
+ docs_refs:
10277
+ - docs/reference/pages.md#rich_text
10278
+ - docs/guides/dashboards.md#rich-text
10279
+ code_refs:
10280
+ - lib/lcp_ruby/widgets/data_resolver.rb
10281
+ - app/views/lcp_ruby/widgets/_rich_text.html.erb
10282
+ status: stable
10283
+ - name: Markdown Widget
10284
+ category: dashboards
10285
+ synopsis: 'i18n source parsed by Commonmarker, output sanitized (widget type: markdown,
10286
+ content_key:). Raw HTML in source is stripped (`unsafe:` deliberately omitted).'
10287
+ description: |-
10288
+ A widget zone with `type: markdown` that runs the i18n source through Commonmarker (CommonMark + GFM tables / tasklists / strikethrough / autolinks) and sanitizes the rendered HTML against the same tag/attribute allow-list as the model-level `:markdown` renderer (`display/renderers/markdown.rb`).
10289
+
10290
+ `unsafe:` is deliberately omitted from the Commonmarker options — raw HTML embedded in the markdown source is stripped at parse time. Friendlier authoring than HTML for hand-written welcome blurbs, and the safer choice when translations come from an external pipeline.
10291
+ config_example: |-
10292
+ ```yaml
10293
+ - name: welcome
10294
+ type: widget
10295
+ widget:
10296
+ type: markdown
10297
+ content_key: lcp_ruby.dashboard.welcome_md
10298
+ position: { row: 1, col: 10, width: 3 }
10299
+ ```
10300
+
10301
+ ```yaml
10302
+ # en.yml
10303
+ en:
10304
+ lcp_ruby:
10305
+ dashboard:
10306
+ welcome_md: |
10307
+ ## Welcome
10308
+
10309
+ Mix **KPIs**, lists, charts, and *text-style* widgets on one page.
10310
+ ```
10311
+ demo_path: "/showcase/showcase-dashboard"
10312
+ demo_hint: The "Welcome" zone in the top-right (row 1, col 10) renders Markdown
10313
+ from the `lcp_ruby.dashboard.welcome_md` key — `##` becomes `<h2>`, `**bold**`
10314
+ becomes `<strong>`.
10315
+ docs_refs:
10316
+ - docs/reference/pages.md#markdown
10317
+ - docs/guides/dashboards.md#markdown
10318
+ code_refs:
10319
+ - lib/lcp_ruby/widgets/data_resolver.rb
10320
+ - app/views/lcp_ruby/widgets/_markdown.html.erb
10321
+ - lib/lcp_ruby/display/renderers/markdown.rb
10242
10322
  status: stable
10243
10323
  - name: List Widget
10244
10324
  category: dashboards