fh-pydantic-form 0.2.5__py3-none-any.whl → 0.3.1__py3-none-any.whl

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.

Potentially problematic release.


This version of fh-pydantic-form might be problematic. Click here for more details.

@@ -1,4 +1,5 @@
1
1
  import logging
2
+ import re
2
3
  from datetime import date, time
3
4
  from enum import Enum
4
5
  from typing import (
@@ -15,9 +16,17 @@ from fastcore.xml import FT
15
16
  from pydantic import ValidationError
16
17
  from pydantic.fields import FieldInfo
17
18
 
19
+ from fh_pydantic_form.color_utils import (
20
+ DEFAULT_METRIC_GREY,
21
+ get_metric_colors,
22
+ robust_color_to_rgba,
23
+ )
18
24
  from fh_pydantic_form.constants import _UNSET
19
25
  from fh_pydantic_form.registry import FieldRendererRegistry
20
26
  from fh_pydantic_form.type_helpers import (
27
+ DecorationScope,
28
+ MetricEntry,
29
+ MetricsDict,
21
30
  _get_underlying_type_if_optional,
22
31
  _is_optional_type,
23
32
  get_default,
@@ -33,6 +42,51 @@ from fh_pydantic_form.ui_style import (
33
42
  logger = logging.getLogger(__name__)
34
43
 
35
44
 
45
+ def _is_form_control(node: Any) -> bool:
46
+ """
47
+ Returns True if this node is a form control element that should receive highlighting.
48
+
49
+ Detects both raw HTML form controls and MonsterUI wrapper components.
50
+ """
51
+ if not hasattr(node, "tag"):
52
+ return False
53
+
54
+ tag = str(getattr(node, "tag", "")).lower()
55
+
56
+ # Raw HTML controls
57
+ if tag in ("input", "select", "textarea"):
58
+ return True
59
+
60
+ # For MonsterUI components, highlight the outer div container instead of inner elements
61
+ # This provides better visual feedback since MonsterUI hides the actual select elements
62
+ if hasattr(node, "attrs") and hasattr(node, "children"):
63
+ classes = str(node.attrs.get("cls", "") or node.attrs.get("class", ""))
64
+
65
+ # Check if this div contains a MonsterUI component
66
+ if tag == "div" and node.children:
67
+ for child in node.children:
68
+ child_tag = str(getattr(child, "tag", "")).lower()
69
+ if child_tag.startswith("uk-") and any(
70
+ control in child_tag for control in ["select", "input", "checkbox"]
71
+ ):
72
+ return True
73
+
74
+ # Also check for direct MonsterUI wrapper classes
75
+ if tag == "div" and "uk-select" in classes:
76
+ return True
77
+
78
+ # MonsterUI typically uses uk- prefixed classes
79
+ if any(
80
+ c
81
+ for c in classes.split()
82
+ if c.startswith("uk-")
83
+ and any(t in c for t in ["input", "select", "checkbox"])
84
+ ):
85
+ return True
86
+
87
+ return False
88
+
89
+
36
90
  def _merge_cls(base: str, extra: str) -> str:
37
91
  """Return base plus extra class(es) separated by a single space (handles blanks)."""
38
92
  if extra:
@@ -42,7 +96,265 @@ def _merge_cls(base: str, extra: str) -> str:
42
96
  return base
43
97
 
44
98
 
45
- class BaseFieldRenderer:
99
+ class MetricsRendererMixin:
100
+ """Mixin to add metrics highlighting capabilities to field renderers"""
101
+
102
+ def _decorate_label(
103
+ self,
104
+ label: FT,
105
+ metric_entry: Optional[MetricEntry],
106
+ ) -> FT:
107
+ """
108
+ Decorate a label element with a metric badge (bullet) if applicable.
109
+ """
110
+ return self._decorate_metrics(label, metric_entry, scope=DecorationScope.BULLET)
111
+
112
+ def _decorate_metrics(
113
+ self,
114
+ element: FT,
115
+ metric_entry: Optional[MetricEntry],
116
+ *,
117
+ scope: DecorationScope = DecorationScope.BOTH,
118
+ ) -> FT:
119
+ """
120
+ Decorate an element with metrics visual feedback.
121
+
122
+ Args:
123
+ element: The FastHTML element to decorate
124
+ metric_entry: Optional metric entry with color, score, and comment
125
+ scope: Which decorations to apply (BORDER, BULLET, or BOTH)
126
+
127
+ Returns:
128
+ Decorated element with left color bar, tooltip, and optional metric badge
129
+ """
130
+ if not metric_entry:
131
+ return element
132
+
133
+ # Add tooltip with comment if available
134
+ comment = metric_entry.get("comment")
135
+ if comment and hasattr(element, "attrs"):
136
+ element.attrs["uk-tooltip"] = comment
137
+ element.attrs["title"] = comment # Fallback standard tooltip
138
+
139
+ # Add left color bar if requested
140
+ if scope in {DecorationScope.BORDER, DecorationScope.BOTH}:
141
+ border_color = self._metric_border_color(metric_entry)
142
+ if border_color and hasattr(element, "attrs"):
143
+ existing_style = element.attrs.get("style", "")
144
+ element.attrs["style"] = (
145
+ f"border-left: 4px solid {border_color}; padding-left: 0.25rem; position: relative; z-index: 0; {existing_style}"
146
+ )
147
+
148
+ # Add metric score badge if requested and present
149
+ score = metric_entry.get("metric")
150
+ color = metric_entry.get("color")
151
+ if (
152
+ scope in {DecorationScope.BULLET, DecorationScope.BOTH}
153
+ and score is not None
154
+ ):
155
+ # Determine bullet colors based on LangSmith-style system when no color provided
156
+ if color:
157
+ # Use provided color - convert to full opacity for badge
158
+ badge_bg_rgba = robust_color_to_rgba(color, 1.0)
159
+ # Extract RGB values and use them for badge background
160
+ rgb_match = re.match(
161
+ r"rgba\((\d+),\s*(\d+),\s*(\d+),\s*[\d.]+\)", badge_bg_rgba
162
+ )
163
+ if rgb_match:
164
+ r, g, b = rgb_match.groups()
165
+ badge_bg = f"rgb({r}, {g}, {b})"
166
+ else:
167
+ badge_bg = color
168
+ text_color = "white"
169
+ else:
170
+ # Use metric-based color system
171
+ badge_bg, text_color = get_metric_colors(score)
172
+
173
+ # Create custom styled span that looks like a bullet/pill
174
+ metric_badge = fh.Span(
175
+ str(score),
176
+ style=f"""
177
+ background-color: {badge_bg};
178
+ color: {text_color};
179
+ padding: 0.125rem 0.5rem;
180
+ border-radius: 9999px;
181
+ font-size: 0.75rem;
182
+ font-weight: 500;
183
+ display: inline-block;
184
+ margin-left: 0.5rem;
185
+ vertical-align: top;
186
+ line-height: 1.25;
187
+ white-space: nowrap;
188
+ text-shadow: 0 1px 2px rgba(0,0,0,0.1);
189
+ """,
190
+ cls="uk-text-nowrap",
191
+ )
192
+
193
+ # Use helper to attach badge properly
194
+ return self._attach_metric_badge(element, metric_badge)
195
+
196
+ return element
197
+
198
+ def _attach_metric_badge(self, element: FT, badge: FT) -> FT:
199
+ """
200
+ Attach a metric badge to an element in the most appropriate way.
201
+
202
+ Args:
203
+ element: The element to attach the badge to
204
+ badge: The badge element to attach
205
+
206
+ Returns:
207
+ Element with badge attached
208
+ """
209
+ # Check if element is an inline-capable tag
210
+ tag = str(getattr(element, "tag", "")).lower()
211
+ inline_tags = {"span", "a", "h1", "h2", "h3", "h4", "h5", "h6", "label"}
212
+
213
+ if tag in inline_tags and hasattr(element, "children"):
214
+ # For inline elements, append badge directly to children
215
+ if isinstance(element.children, list):
216
+ element.children.append(badge)
217
+ else:
218
+ # Convert to list if needed
219
+ element.children = list(element.children) + [badge]
220
+ return element
221
+
222
+ # For other elements, wrap in a flex container
223
+ return fh.Div(element, badge, cls="relative inline-flex items-center w-full")
224
+
225
+ def _highlight_input_fields(self, element: FT, metric_entry: MetricEntry) -> FT:
226
+ """
227
+ Find nested form controls and add a colored box-shadow to them
228
+ based on the metric entry color.
229
+
230
+ Args:
231
+ element: The FT element to search within
232
+ metric_entry: The metric entry containing color information
233
+
234
+ Returns:
235
+ The element with highlighted input fields
236
+ """
237
+ if not metric_entry:
238
+ return element
239
+
240
+ # Determine the color to use for highlighting
241
+ color = metric_entry.get("color")
242
+ score = metric_entry.get("metric")
243
+
244
+ if color:
245
+ # Use the provided color
246
+ highlight_color = color
247
+ elif score is not None:
248
+ # Use metric-based color system (background color from the helper)
249
+ highlight_color, _ = get_metric_colors(score)
250
+ else:
251
+ # No color or metric available
252
+ return element
253
+
254
+ # Create the highlight CSS with appropriate opacity for both border and background
255
+ # Use !important to ensure our styles override MonsterUI defaults
256
+ # Focus on border highlighting since background might conflict with MonsterUI styling
257
+ border_rgba = robust_color_to_rgba(highlight_color, 0.8)
258
+ background_rgba = robust_color_to_rgba(highlight_color, 0.1)
259
+ highlight_css = f"border: 2px solid {border_rgba} !important; border-radius: 4px !important; box-shadow: 0 0 0 1px {border_rgba} !important; background-color: {background_rgba} !important;"
260
+
261
+ # Track how many elements we highlight
262
+ highlight_count = 0
263
+
264
+ # Recursively find and style input elements
265
+ def apply_highlight(node):
266
+ """Recursively apply highlighting to input elements"""
267
+ nonlocal highlight_count
268
+
269
+ if _is_form_control(node):
270
+ # Add or update the style attribute
271
+ if hasattr(node, "attrs"):
272
+ existing_style = node.attrs.get("style", "")
273
+ node.attrs["style"] = highlight_css + " " + existing_style
274
+ highlight_count += 1
275
+ logger.debug(
276
+ f"Applied highlight to tag={getattr(node, 'tag', 'unknown')}, attrs={list(node.attrs.keys()) if hasattr(node, 'attrs') else []}"
277
+ )
278
+
279
+ # Process children if they exist
280
+ if hasattr(node, "children") and node.children:
281
+ for child in node.children:
282
+ apply_highlight(child)
283
+
284
+ # Apply highlighting to the element tree
285
+ apply_highlight(element)
286
+
287
+ if highlight_count == 0:
288
+ logger.debug("No form controls found to highlight in element tree")
289
+
290
+ return element
291
+
292
+ def _metric_border_color(
293
+ self, metric_entry: Optional[MetricEntry]
294
+ ) -> Optional[str]:
295
+ """
296
+ Get an RGBA color string for a metric entry's left border bar.
297
+
298
+ Args:
299
+ metric_entry: The metric entry containing color/score information
300
+
301
+ Returns:
302
+ RGBA color string for left border bar, or None if no metric
303
+ """
304
+ if not metric_entry:
305
+ return None
306
+
307
+ # Use provided color if available
308
+ if metric_entry.get("color"):
309
+ return robust_color_to_rgba(metric_entry["color"], 0.8)
310
+
311
+ # Otherwise derive from metric score
312
+ metric = metric_entry.get("metric")
313
+ if metric is not None:
314
+ color, _ = get_metric_colors(metric)
315
+ # If get_metric_colors returns the fallback grey, use our unified light grey
316
+ if color == DEFAULT_METRIC_GREY:
317
+ return DEFAULT_METRIC_GREY
318
+ return robust_color_to_rgba(color, 0.8)
319
+
320
+ # If only a comment is present, return the unified light grey color
321
+ if metric_entry.get("comment"):
322
+ return DEFAULT_METRIC_GREY
323
+
324
+ return None
325
+
326
+
327
+ def _build_path_string_static(path_segments: List[str]) -> str:
328
+ """
329
+ Static version of BaseFieldRenderer._build_path_string for use without instance.
330
+
331
+ Convert field_path list to dot/bracket notation string for metric lookup.
332
+
333
+ Examples:
334
+ ['experience', '0', 'company'] -> 'experience[0].company'
335
+ ['skills', 'programming_languages', '2'] -> 'skills.programming_languages[2]'
336
+
337
+ Args:
338
+ path_segments: List of path segments
339
+
340
+ Returns:
341
+ Path string in dot/bracket notation
342
+ """
343
+ parts: List[str] = []
344
+ for segment in path_segments:
345
+ # Check if segment is numeric or a list index pattern
346
+ if segment.isdigit() or segment.startswith("new_"):
347
+ # Interpret as list index
348
+ if parts:
349
+ parts[-1] += f"[{segment}]"
350
+ else: # Defensive fallback
351
+ parts.append(f"[{segment}]")
352
+ else:
353
+ parts.append(segment)
354
+ return ".".join(parts)
355
+
356
+
357
+ class BaseFieldRenderer(MetricsRendererMixin):
46
358
  """
47
359
  Base class for field renderers
48
360
 
@@ -50,6 +362,7 @@ class BaseFieldRenderer:
50
362
  - Rendering a label for the field
51
363
  - Rendering an appropriate input element for the field
52
364
  - Combining the label and input with proper spacing
365
+ - Optionally applying comparison visual feedback
53
366
 
54
367
  Subclasses must implement render_input()
55
368
  """
@@ -65,6 +378,10 @@ class BaseFieldRenderer:
65
378
  spacing: SpacingValue = SpacingTheme.NORMAL,
66
379
  field_path: Optional[List[str]] = None,
67
380
  form_name: Optional[str] = None,
381
+ metric_entry: Optional[MetricEntry] = None,
382
+ metrics_dict: Optional[MetricsDict] = None,
383
+ refresh_endpoint_override: Optional[str] = None,
384
+ **kwargs, # Accept additional kwargs for extensibility
68
385
  ):
69
386
  """
70
387
  Initialize the field renderer
@@ -79,6 +396,10 @@ class BaseFieldRenderer:
79
396
  spacing: Spacing theme to use for layout ("normal", "compact", or SpacingTheme enum)
80
397
  field_path: Path segments from root to this field (for nested list support)
81
398
  form_name: Explicit form name (used for nested list URLs)
399
+ metric_entry: Optional metric entry for visual feedback
400
+ metrics_dict: Optional full metrics dict for auto-lookup
401
+ refresh_endpoint_override: Optional override URL for refresh actions (used in ComparisonForm)
402
+ **kwargs: Additional keyword arguments for extensibility
82
403
  """
83
404
  self.field_name = f"{prefix}{field_name}" if prefix else field_name
84
405
  self.original_field_name = field_name
@@ -91,6 +412,42 @@ class BaseFieldRenderer:
91
412
  self.disabled = disabled
92
413
  self.label_color = label_color
93
414
  self.spacing = _normalize_spacing(spacing)
415
+ self.metrics_dict = metrics_dict
416
+ self._refresh_endpoint_override = refresh_endpoint_override
417
+
418
+ # Initialize metric entry attribute
419
+ self.metric_entry: Optional[MetricEntry] = None
420
+
421
+ # Auto-resolve metric entry if not explicitly provided
422
+ if metric_entry is not None:
423
+ self.metric_entry = metric_entry
424
+ elif metrics_dict:
425
+ path_string = self._build_path_string()
426
+ self.metric_entry = metrics_dict.get(path_string)
427
+
428
+ def _build_path_string(self) -> str:
429
+ """
430
+ Convert field_path list to dot/bracket notation string for comparison lookup.
431
+
432
+ Examples:
433
+ ['experience', '0', 'company'] -> 'experience[0].company'
434
+ ['skills', 'programming_languages', '2'] -> 'skills.programming_languages[2]'
435
+
436
+ Returns:
437
+ Path string in dot/bracket notation
438
+ """
439
+ parts: List[str] = []
440
+ for segment in self.field_path:
441
+ # Check if segment is numeric or a list index pattern
442
+ if segment.isdigit() or segment.startswith("new_"):
443
+ # Interpret as list index
444
+ if parts:
445
+ parts[-1] += f"[{segment}]"
446
+ else: # Defensive fallback
447
+ parts.append(f"[{segment}]")
448
+ else:
449
+ parts.append(segment)
450
+ return ".".join(parts)
94
451
 
95
452
  def _is_inline_color(self, color: str) -> bool:
96
453
  """
@@ -211,7 +568,7 @@ class BaseFieldRenderer:
211
568
  # 3. Choose layout based on spacing theme
212
569
  if self.spacing == SpacingTheme.COMPACT:
213
570
  # Horizontal layout for compact mode
214
- return fh.Div(
571
+ field_element = fh.Div(
215
572
  fh.Div(
216
573
  label_component,
217
574
  input_component,
@@ -221,12 +578,15 @@ class BaseFieldRenderer:
221
578
  )
222
579
  else:
223
580
  # Vertical layout for normal mode (existing behavior)
224
- return fh.Div(
581
+ field_element = fh.Div(
225
582
  label_component,
226
583
  input_component,
227
584
  cls=spacing("outer_margin", self.spacing),
228
585
  )
229
586
 
587
+ # 4. Apply metrics decoration if available
588
+ return self._decorate_metrics(field_element, self.metric_entry)
589
+
230
590
 
231
591
  # ---- Specific Field Renderers ----
232
592
 
@@ -234,6 +594,10 @@ class BaseFieldRenderer:
234
594
  class StringFieldRenderer(BaseFieldRenderer):
235
595
  """Renderer for string fields"""
236
596
 
597
+ def __init__(self, *args, **kwargs):
598
+ """Initialize string field renderer, passing all arguments to parent"""
599
+ super().__init__(*args, **kwargs)
600
+
237
601
  def render_input(self) -> FT:
238
602
  """
239
603
  Render input element for the field
@@ -281,6 +645,10 @@ class StringFieldRenderer(BaseFieldRenderer):
281
645
  class NumberFieldRenderer(BaseFieldRenderer):
282
646
  """Renderer for number fields (int, float)"""
283
647
 
648
+ def __init__(self, *args, **kwargs):
649
+ """Initialize number field renderer, passing all arguments to parent"""
650
+ super().__init__(*args, **kwargs)
651
+
284
652
  def render_input(self) -> FT:
285
653
  """
286
654
  Render input element for the field
@@ -328,6 +696,10 @@ class NumberFieldRenderer(BaseFieldRenderer):
328
696
  class BooleanFieldRenderer(BaseFieldRenderer):
329
697
  """Renderer for boolean fields"""
330
698
 
699
+ def __init__(self, *args, **kwargs):
700
+ """Initialize boolean field renderer, passing all arguments to parent"""
701
+ super().__init__(*args, **kwargs)
702
+
331
703
  def render_input(self) -> FT:
332
704
  """
333
705
  Render input element for the field
@@ -356,12 +728,14 @@ class BooleanFieldRenderer(BaseFieldRenderer):
356
728
  """
357
729
  # Get the label component
358
730
  label_component = self.render_label()
731
+ # Decorate the label with the metric badge (bullet)
732
+ label_component = self._decorate_label(label_component, self.metric_entry)
359
733
 
360
734
  # Get the checkbox component
361
735
  checkbox_component = self.render_input()
362
736
 
363
737
  # Create a flex container to place label and checkbox side by side
364
- return fh.Div(
738
+ field_element = fh.Div(
365
739
  fh.Div(
366
740
  label_component,
367
741
  checkbox_component,
@@ -370,10 +744,19 @@ class BooleanFieldRenderer(BaseFieldRenderer):
370
744
  cls=f"{spacing('outer_margin', self.spacing)} w-full",
371
745
  )
372
746
 
747
+ # Apply metrics decoration if available (border only, as bullet is in the label)
748
+ return self._decorate_metrics(
749
+ field_element, self.metric_entry, scope=DecorationScope.BORDER
750
+ )
751
+
373
752
 
374
753
  class DateFieldRenderer(BaseFieldRenderer):
375
754
  """Renderer for date fields"""
376
755
 
756
+ def __init__(self, *args, **kwargs):
757
+ """Initialize date field renderer, passing all arguments to parent"""
758
+ super().__init__(*args, **kwargs)
759
+
377
760
  def render_input(self) -> FT:
378
761
  """
379
762
  Render input element for the field
@@ -425,6 +808,10 @@ class DateFieldRenderer(BaseFieldRenderer):
425
808
  class TimeFieldRenderer(BaseFieldRenderer):
426
809
  """Renderer for time fields"""
427
810
 
811
+ def __init__(self, *args, **kwargs):
812
+ """Initialize time field renderer, passing all arguments to parent"""
813
+ super().__init__(*args, **kwargs)
814
+
428
815
  def render_input(self) -> FT:
429
816
  """
430
817
  Render input element for the field
@@ -433,11 +820,19 @@ class TimeFieldRenderer(BaseFieldRenderer):
433
820
  A TimeInput component appropriate for time values
434
821
  """
435
822
  formatted_value = ""
436
- if (
437
- isinstance(self.value, str) and len(self.value) == 5
438
- ): # Basic check for HH:MM format
439
- # Assume it's the correct string format from the form
440
- formatted_value = self.value
823
+ if isinstance(self.value, str):
824
+ # Try to parse the time string using various formats
825
+ time_formats = ["%H:%M", "%H:%M:%S", "%H:%M:%S.%f"]
826
+
827
+ for fmt in time_formats:
828
+ try:
829
+ from datetime import datetime
830
+
831
+ parsed_time = datetime.strptime(self.value, fmt).time()
832
+ formatted_value = parsed_time.strftime("%H:%M")
833
+ break
834
+ except ValueError:
835
+ continue
441
836
  elif isinstance(self.value, time):
442
837
  formatted_value = self.value.strftime("%H:%M") # HH:MM
443
838
 
@@ -477,6 +872,10 @@ class TimeFieldRenderer(BaseFieldRenderer):
477
872
  class LiteralFieldRenderer(BaseFieldRenderer):
478
873
  """Renderer for Literal fields as dropdown selects"""
479
874
 
875
+ def __init__(self, *args, **kwargs):
876
+ """Initialize literal field renderer, passing all arguments to parent"""
877
+ super().__init__(*args, **kwargs)
878
+
480
879
  def render_input(self) -> FT:
481
880
  """
482
881
  Render input element for the field as a select dropdown
@@ -550,6 +949,10 @@ class LiteralFieldRenderer(BaseFieldRenderer):
550
949
  class EnumFieldRenderer(BaseFieldRenderer):
551
950
  """Renderer for Enum fields as dropdown selects"""
552
951
 
952
+ def __init__(self, *args, **kwargs):
953
+ """Initialize enum field renderer, passing all arguments to parent"""
954
+ super().__init__(*args, **kwargs)
955
+
553
956
  def render_input(self) -> FT:
554
957
  """
555
958
  Render input element for the field as a select dropdown
@@ -635,6 +1038,10 @@ class EnumFieldRenderer(BaseFieldRenderer):
635
1038
  class BaseModelFieldRenderer(BaseFieldRenderer):
636
1039
  """Renderer for nested Pydantic BaseModel fields"""
637
1040
 
1041
+ def __init__(self, *args, **kwargs):
1042
+ """Initialize base model field renderer, passing all arguments to parent"""
1043
+ super().__init__(*args, **kwargs)
1044
+
638
1045
  def render(self) -> FT:
639
1046
  """
640
1047
  Render the nested BaseModel field as a single-item accordion using mui.Accordion.
@@ -673,6 +1080,19 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
673
1080
  title_component.attrs["uk-tooltip"] = description
674
1081
  title_component.attrs["title"] = description
675
1082
 
1083
+ # Apply metrics decoration to title (bullet only, no border)
1084
+ title_component = self._decorate_metrics(
1085
+ title_component, self.metric_entry, scope=DecorationScope.BULLET
1086
+ )
1087
+
1088
+ # Compute border color for the top-level BaseModel card
1089
+ border_color = self._metric_border_color(self.metric_entry)
1090
+ li_style = {}
1091
+ if border_color:
1092
+ li_style["style"] = (
1093
+ f"border-left: 4px solid {border_color}; padding-left: 0.25rem;"
1094
+ )
1095
+
676
1096
  # 2. Render the nested input fields that will be the accordion content
677
1097
  input_component = self.render_input()
678
1098
 
@@ -685,7 +1105,10 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
685
1105
  title_component, # Title component with proper color styling
686
1106
  input_component, # Content component (the Card with nested fields)
687
1107
  open=True, # Open by default
688
- li_kwargs={"id": item_id}, # Pass the specific ID for the <li>
1108
+ li_kwargs={
1109
+ "id": item_id,
1110
+ **li_style,
1111
+ }, # Pass the specific ID and style for the <li>
689
1112
  cls=spacing(
690
1113
  "outer_margin", self.spacing
691
1114
  ), # Add bottom margin to the <li> element
@@ -703,6 +1126,8 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
703
1126
  cls=f"{accordion_cls} w-full".strip(),
704
1127
  )
705
1128
 
1129
+ # 6. Apply metrics decoration to the title only (bullet), not the container
1130
+ # The parent list renderer handles the border decoration
706
1131
  return accordion_container
707
1132
 
708
1133
  def render_input(self) -> FT:
@@ -786,8 +1211,12 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
786
1211
  prefix=nested_prefix,
787
1212
  disabled=self.disabled, # Propagate disabled state to nested fields
788
1213
  spacing=self.spacing, # Propagate spacing to nested fields
789
- field_path=self.field_path + [nested_field_name], # Propagate path
1214
+ field_path=self.field_path
1215
+ + [nested_field_name], # Propagate path with field name
790
1216
  form_name=self.explicit_form_name, # Propagate form name
1217
+ metric_entry=None, # Let auto-lookup handle it
1218
+ metrics_dict=self.metrics_dict, # Pass down the metrics dict
1219
+ refresh_endpoint_override=self._refresh_endpoint_override, # Propagate refresh override
791
1220
  )
792
1221
 
793
1222
  nested_inputs.append(renderer.render())
@@ -822,6 +1251,56 @@ class BaseModelFieldRenderer(BaseFieldRenderer):
822
1251
  class ListFieldRenderer(BaseFieldRenderer):
823
1252
  """Renderer for list fields containing any type"""
824
1253
 
1254
+ def __init__(
1255
+ self,
1256
+ field_name: str,
1257
+ field_info: FieldInfo,
1258
+ value: Any = None,
1259
+ prefix: str = "",
1260
+ disabled: bool = False,
1261
+ label_color: Optional[str] = None,
1262
+ spacing: SpacingValue = SpacingTheme.NORMAL,
1263
+ field_path: Optional[List[str]] = None,
1264
+ form_name: Optional[str] = None,
1265
+ metric_entry: Optional[MetricEntry] = None,
1266
+ metrics_dict: Optional[MetricsDict] = None,
1267
+ show_item_border: bool = True,
1268
+ **kwargs, # Accept additional kwargs
1269
+ ):
1270
+ """
1271
+ Initialize the list field renderer
1272
+
1273
+ Args:
1274
+ field_name: The name of the field
1275
+ field_info: The FieldInfo for the field
1276
+ value: The current value of the field (optional)
1277
+ prefix: Optional prefix for the field name (used for nested fields)
1278
+ disabled: Whether the field should be rendered as disabled
1279
+ label_color: Optional CSS color value for the field label
1280
+ spacing: Spacing theme to use for layout
1281
+ field_path: Path segments from root to this field
1282
+ form_name: Explicit form name
1283
+ metric_entry: Optional metric entry for visual feedback
1284
+ metrics_dict: Optional full metrics dict for auto-lookup
1285
+ show_item_border: Whether to show colored borders on list items based on metrics
1286
+ **kwargs: Additional keyword arguments passed to parent
1287
+ """
1288
+ super().__init__(
1289
+ field_name=field_name,
1290
+ field_info=field_info,
1291
+ value=value,
1292
+ prefix=prefix,
1293
+ disabled=disabled,
1294
+ label_color=label_color,
1295
+ spacing=spacing,
1296
+ field_path=field_path,
1297
+ form_name=form_name,
1298
+ metric_entry=metric_entry,
1299
+ metrics_dict=metrics_dict,
1300
+ **kwargs, # Pass kwargs to parent
1301
+ )
1302
+ self.show_item_border = show_item_border
1303
+
825
1304
  def _container_id(self) -> str:
826
1305
  """
827
1306
  Return a DOM-unique ID for the list's <ul> / <div> wrapper.
@@ -882,8 +1361,10 @@ class ListFieldRenderer(BaseFieldRenderer):
882
1361
  # form_name = self.prefix.rstrip("_") if self.prefix else None
883
1362
  form_name = self._form_name or None
884
1363
 
885
- # Create the label text with proper color styling
886
- label_text = self.original_field_name.replace("_", " ").title()
1364
+ # Create the label text with proper color styling and item count
1365
+ items = [] if not isinstance(self.value, list) else self.value
1366
+ item_count = len(items)
1367
+ label_text = f"{self.original_field_name.replace('_', ' ').title()} ({item_count} item{'s' if item_count != 1 else ''})"
887
1368
 
888
1369
  # Create the styled label span
889
1370
  if self.label_color:
@@ -913,6 +1394,9 @@ class ListFieldRenderer(BaseFieldRenderer):
913
1394
  label_span.attrs["uk-tooltip"] = description
914
1395
  label_span.attrs["title"] = description
915
1396
 
1397
+ # Decorate the label span with the metric badge (bullet)
1398
+ label_span = self._decorate_label(label_span, self.metric_entry)
1399
+
916
1400
  # Construct the container ID that will be generated by render_input()
917
1401
  container_id = self._container_id()
918
1402
 
@@ -925,13 +1409,23 @@ class ListFieldRenderer(BaseFieldRenderer):
925
1409
  )
926
1410
 
927
1411
  # Create the clickable span wrapper for the icon
1412
+ # Use prefix-based selector to include only fields from this form
1413
+ hx_include_selector = (
1414
+ f"form [name^='{self.prefix}']" if self.prefix else "closest form"
1415
+ )
1416
+
1417
+ # Use override endpoint if provided (for ComparisonForm), otherwise use standard form refresh
1418
+ refresh_url = (
1419
+ self._refresh_endpoint_override or f"/form/{form_name}/refresh"
1420
+ )
1421
+
928
1422
  refresh_icon_trigger = fh.Span(
929
1423
  refresh_icon_component,
930
1424
  cls="ml-1 inline-block align-middle cursor-pointer", # Add margin, ensure inline-like behavior
931
- hx_post=f"/form/{form_name}/refresh",
1425
+ hx_post=refresh_url,
932
1426
  hx_target=f"#{form_name}-inputs-wrapper",
933
1427
  hx_swap="innerHTML",
934
- hx_include="closest form", # key change
1428
+ hx_include=hx_include_selector, # Use prefix-based selector
935
1429
  uk_tooltip="Refresh form display to update list summaries",
936
1430
  # Prevent 'toggleListItems' on the parent from firing
937
1431
  onclick="event.stopPropagation();",
@@ -954,12 +1448,19 @@ class ListFieldRenderer(BaseFieldRenderer):
954
1448
  )
955
1449
 
956
1450
  # Return container with label+icon and input
957
- return fh.Div(
1451
+ field_element = fh.Div(
958
1452
  label_with_icon,
959
1453
  self.render_input(),
960
1454
  cls=f"{spacing('outer_margin', self.spacing)} w-full",
961
1455
  )
962
1456
 
1457
+ # Apply metrics decoration if available
1458
+ return self._decorate_metrics(
1459
+ field_element,
1460
+ self.metric_entry,
1461
+ scope=DecorationScope.BORDER,
1462
+ )
1463
+
963
1464
  def render_input(self) -> FT:
964
1465
  """
965
1466
  Render a list of items with add/delete/move capabilities
@@ -971,6 +1472,7 @@ class ListFieldRenderer(BaseFieldRenderer):
971
1472
  items = [] if not isinstance(self.value, list) else self.value
972
1473
 
973
1474
  annotation = getattr(self.field_info, "annotation", None)
1475
+ item_type = None # Initialize here to avoid UnboundLocalError
974
1476
 
975
1477
  if (
976
1478
  annotation is not None
@@ -1085,6 +1587,13 @@ class ListFieldRenderer(BaseFieldRenderer):
1085
1587
  item_id = f"{self.field_name}_{idx}"
1086
1588
  item_card_id = f"{item_id}_card"
1087
1589
 
1590
+ # Look up metrics for this list item
1591
+ item_path_segments = self.field_path + [str(idx)]
1592
+ path_string = _build_path_string_static(item_path_segments)
1593
+ item_metric_entry: Optional[MetricEntry] = (
1594
+ self.metrics_dict.get(path_string) if self.metrics_dict else None
1595
+ )
1596
+
1088
1597
  # Check if it's a simple type or BaseModel
1089
1598
  is_model = hasattr(item_type, "model_fields")
1090
1599
 
@@ -1109,9 +1618,7 @@ class ListFieldRenderer(BaseFieldRenderer):
1109
1618
 
1110
1619
  if model_for_display is not None:
1111
1620
  # Use the model's __str__ method
1112
- item_summary_text = (
1113
- f"{item_type.__name__}: {str(model_for_display)}"
1114
- )
1621
+ item_summary_text = f"{idx}: {str(model_for_display)}"
1115
1622
  else:
1116
1623
  # Fallback for None or unexpected types
1117
1624
  item_summary_text = f"{item_type.__name__}: (Unknown format: {type(item).__name__})"
@@ -1136,7 +1643,7 @@ class ListFieldRenderer(BaseFieldRenderer):
1136
1643
  )
1137
1644
  item_summary_text = f"{item_type.__name__}: (Error displaying item)"
1138
1645
  else:
1139
- item_summary_text = str(item)
1646
+ item_summary_text = f"{idx}: {str(item)}"
1140
1647
 
1141
1648
  # --- Render item content elements ---
1142
1649
  item_content_elements = []
@@ -1179,6 +1686,13 @@ class ListFieldRenderer(BaseFieldRenderer):
1179
1686
  value=item,
1180
1687
  prefix=self.prefix,
1181
1688
  disabled=self.disabled, # Propagate disabled state
1689
+ spacing=self.spacing, # Propagate spacing
1690
+ field_path=self.field_path
1691
+ + [str(idx)], # Propagate path with index
1692
+ form_name=self.explicit_form_name, # Propagate form name
1693
+ metric_entry=None, # Let auto-lookup handle it
1694
+ metrics_dict=self.metrics_dict, # Pass down the metrics dict
1695
+ refresh_endpoint_override=self._refresh_endpoint_override, # Propagate refresh override
1182
1696
  )
1183
1697
  # Add the rendered input to content elements
1184
1698
  item_content_elements.append(item_renderer.render_input())
@@ -1234,6 +1748,10 @@ class ListFieldRenderer(BaseFieldRenderer):
1234
1748
  str(idx),
1235
1749
  nested_field_name,
1236
1750
  ], # Propagate path with index
1751
+ form_name=self.explicit_form_name, # Propagate form name
1752
+ metric_entry=None, # Let auto-lookup handle it
1753
+ metrics_dict=self.metrics_dict, # Pass down the metrics dict
1754
+ refresh_endpoint_override=self._refresh_endpoint_override, # Propagate refresh override
1237
1755
  )
1238
1756
 
1239
1757
  # Add rendered field to valid fields
@@ -1271,9 +1789,15 @@ class ListFieldRenderer(BaseFieldRenderer):
1271
1789
  spacing=self.spacing, # Propagate spacing
1272
1790
  field_path=self.field_path
1273
1791
  + [str(idx)], # Propagate path with index
1792
+ form_name=self.explicit_form_name, # Propagate form name
1793
+ metric_entry=None, # Let auto-lookup handle it
1794
+ metrics_dict=self.metrics_dict, # Pass down the metrics dict
1795
+ refresh_endpoint_override=self._refresh_endpoint_override, # Propagate refresh override
1274
1796
  )
1275
1797
  input_element = simple_renderer.render_input()
1276
- item_content_elements.append(fh.Div(input_element))
1798
+ wrapper = fh.Div(input_element)
1799
+ # Don't apply metrics decoration here - the card border handles it
1800
+ item_content_elements.append(wrapper)
1277
1801
 
1278
1802
  # --- Create action buttons with form-specific URLs ---
1279
1803
  # Generate HTMX endpoints using hierarchical paths
@@ -1373,8 +1897,23 @@ class ListFieldRenderer(BaseFieldRenderer):
1373
1897
  title_component = fh.Span(
1374
1898
  item_summary_text, cls="text-gray-700 font-medium pl-3"
1375
1899
  )
1900
+
1901
+ # Apply metrics decoration to the title (bullet only)
1902
+ title_component = self._decorate_metrics(
1903
+ title_component, item_metric_entry, scope=DecorationScope.BULLET
1904
+ )
1905
+
1906
+ # Prepare li attributes with optional border styling
1376
1907
  li_attrs = {"id": full_card_id}
1377
1908
 
1909
+ # Add colored border based on metrics if enabled
1910
+ if self.show_item_border and item_metric_entry:
1911
+ border_color = self._metric_border_color(item_metric_entry)
1912
+ if border_color:
1913
+ li_attrs["style"] = (
1914
+ f"border-left: 4px solid {border_color}; padding-left: 0.25rem;"
1915
+ )
1916
+
1378
1917
  # Build card classes using spacing tokens
1379
1918
  card_cls_parts = ["uk-card"]
1380
1919
  if self.spacing == SpacingTheme.NORMAL:
@@ -1400,12 +1939,34 @@ class ListFieldRenderer(BaseFieldRenderer):
1400
1939
 
1401
1940
  except Exception as e:
1402
1941
  # Return error representation
1403
- title_component = f"Error in item {idx}"
1942
+
1943
+ # Still try to get metrics for error items
1944
+ item_path_segments = self.field_path + [str(idx)]
1945
+ path_string = _build_path_string_static(item_path_segments)
1946
+
1947
+ title_component = fh.Span(
1948
+ f"Error in item {idx}", cls="text-red-600 font-medium pl-3"
1949
+ )
1950
+
1951
+ # Apply metrics decoration even to error items (bullet only)
1952
+ title_component = self._decorate_metrics(
1953
+ title_component, item_metric_entry, scope=DecorationScope.BULLET
1954
+ )
1955
+
1404
1956
  content_component = mui.Alert(
1405
1957
  f"Error rendering item {idx}: {str(e)}", cls=mui.AlertT.error
1406
1958
  )
1959
+
1407
1960
  li_attrs = {"id": f"{self.field_name}_{idx}_error_card"}
1408
1961
 
1962
+ # Add colored border for error items too if metrics present
1963
+ if self.show_item_border and item_metric_entry:
1964
+ border_color = self._metric_border_color(item_metric_entry)
1965
+ if border_color:
1966
+ li_attrs["style"] = (
1967
+ f"border-left: 4px solid {border_color}; padding-left: 0.25rem;"
1968
+ )
1969
+
1409
1970
  # Wrap error component in a div with consistent padding
1410
1971
  t = self.spacing
1411
1972
  content_wrapper = fh.Div(content_component, cls=spacing("card_body_pad", t))