Skip to content

Commit 633c890

Browse files
committed
refactor(html): refresh report layouts, findings cards, and metric delta badges
1 parent 50901f5 commit 633c890

16 files changed

Lines changed: 435 additions & 334 deletions

codeclone/_html_badges.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,12 +131,17 @@ def _stat_card(
131131
tone: str = "",
132132
css_class: str = "meta-item",
133133
glossary_tip_fn: Callable[[str], str] | None = None,
134+
delta_new: int | None = None,
134135
) -> str:
135136
"""Unified stat-card renderer.
136137
137138
Always emits the same HTML structure using ``.meta-item`` /
138139
``.meta-label`` / ``.meta-value`` so every stat card shares the
139140
exact same design code.
141+
142+
*delta_new* — if provided and > 0, renders a ``+N new`` badge below
143+
the detail line. For "bad" metrics (complexity, coupling, etc.)
144+
positive delta means regression → red; zero means no change → hidden.
140145
"""
141146
tip_html = ""
142147
if glossary_tip_fn is not None:
@@ -148,12 +153,17 @@ def _stat_card(
148153
if detail:
149154
detail_html = f'<div class="kpi-detail">{_escape_html(detail)}</div>'
150155

156+
delta_html = ""
157+
if delta_new is not None and delta_new > 0:
158+
delta_html = f'<div class="kpi-delta kpi-delta--bad">+{delta_new} new</div>'
159+
151160
tone_cls = f" dep-stat-{tone}" if tone else ""
152161

153162
return (
154163
f'<div class="{_escape_attr(css_class)}{tone_cls}">'
155164
f'<div class="meta-label">{_escape_html(label)}{tip_html}</div>'
156165
f'<div class="meta-value">{_escape_html(str(value))}</div>'
157166
f"{detail_html}"
167+
f"{delta_html}"
158168
"</div>"
159169
)

codeclone/_html_css.py

Lines changed: 126 additions & 58 deletions
Large diffs are not rendered by default.

codeclone/_html_filters.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def _render_select(
4444
parts = [
4545
f'<select class="select" id="{_escape_attr(element_id)}" '
4646
f"{data_attr}>"
47-
f'<option value="all">{_escape_html(all_label)}</option>',
47+
f'<option value="">{_escape_html(all_label)}</option>',
4848
]
4949
for value, display in options:
5050
sel = " selected" if selected == value else ""

codeclone/_html_js.py

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -258,10 +258,10 @@
258258

259259
_MODALS = """\
260260
(function initModals(){
261-
let dlg=$('dialog.modal');
261+
let dlg=$('#clone-info-modal');
262262
if(!dlg){
263263
dlg=document.createElement('dialog');
264-
dlg.className='modal';
264+
dlg.id='clone-info-modal';
265265
dlg.innerHTML='<div class="modal-head"><h2 id="modal-title">Info</h2>'
266266
+'<button class="modal-close" type="button" aria-label="Close">&times;</button></div>'
267267
+'<div class="modal-body" id="modal-body"></div>';
@@ -277,23 +277,26 @@
277277
const group=btn.closest('.group');
278278
if(!group)return;
279279
const d=group.dataset;
280-
const lines=[];
281-
if(d.matchRule)lines.push('<b>Match rule:</b> '+d.matchRule);
282-
if(d.blockSize)lines.push('<b>Block size:</b> '+d.blockSize);
283-
if(d.signatureKind)lines.push('<b>Signature:</b> '+d.signatureKind);
284-
if(d.mergedRegions)lines.push('<b>Merged regions:</b> '+d.mergedRegions);
285-
if(d.patternLabel)lines.push('<b>Pattern:</b> '+d.patternLabel);
286-
if(d.hintLabel)lines.push('<b>Hint:</b> '+d.hintLabel);
287-
if(d.hintConfidence)lines.push('<b>Hint confidence:</b> '+d.hintConfidence);
288-
if(d.assertRatio)lines.push('<b>Assert ratio:</b> '+d.assertRatio);
289-
if(d.consecutiveAsserts)lines.push('<b>Consecutive asserts:</b> '+d.consecutiveAsserts);
290-
if(d.boilerplateAsserts)lines.push('<b>Boilerplate asserts:</b> '+d.boilerplateAsserts);
291-
if(d.groupArity)lines.push('<b>Group arity:</b> '+d.groupArity);
292-
if(d.cloneType)lines.push('<b>Clone type:</b> '+d.cloneType);
293-
if(d.sourceKind)lines.push('<b>Source kind:</b> '+d.sourceKind);
294-
if(d.spreadFiles)lines.push('<b>Spread:</b> '+d.spreadFunctions+' fn / '+d.spreadFiles+' files');
280+
const items=[];
281+
function add(label,val){if(val)items.push('<div><dt>'+label+'</dt><dd>'+val+'</dd></div>')}
282+
add('Match rule',d.matchRule);
283+
add('Block size',d.blockSize);
284+
add('Signature',d.signatureKind);
285+
add('Merged regions',d.mergedRegions);
286+
add('Pattern',d.patternLabel);
287+
add('Hint',d.hintLabel);
288+
add('Hint confidence',d.hintConfidence);
289+
add('Assert ratio',d.assertRatio);
290+
add('Consecutive asserts',d.consecutiveAsserts);
291+
add('Boilerplate asserts',d.boilerplateAsserts);
292+
add('Group arity',d.groupArity);
293+
add('Clone type',d.cloneType);
294+
add('Source kind',d.sourceKind);
295+
if(d.spreadFiles)add('Spread',d.spreadFunctions+' fn / '+d.spreadFiles+' files');
295296
dlg.querySelector('#modal-title').textContent='Group: '+groupId;
296-
dlg.querySelector('#modal-body').innerHTML=lines.join('<br>')||'No metadata available.';
297+
dlg.querySelector('#modal-body').innerHTML=items.length
298+
?'<dl class="info-dl">'+items.join('')+'</dl>'
299+
:'<p class="muted">No metadata available.</p>';
297300
dlg.showModal();
298301
});
299302
})();

codeclone/_html_report/_assemble.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
from ._sections._suggestions import render_suggestions_panel
3030

3131
if TYPE_CHECKING:
32-
from ..models import GroupMapLike, StructuralFindingGroup, Suggestion
32+
from ..models import GroupMapLike, MetricsDiff, StructuralFindingGroup, Suggestion
3333

3434

3535
def build_html_report(
@@ -45,6 +45,7 @@ def build_html_report(
4545
suggestions: Sequence[Suggestion] | None = None,
4646
structural_findings: Sequence[StructuralFindingGroup] | None = None,
4747
report_document: Mapping[str, object] | None = None,
48+
metrics_diff: MetricsDiff | None = None,
4849
title: str = "CodeClone Report",
4950
context_lines: int = 3,
5051
max_snippet_lines: int = 220,
@@ -67,6 +68,7 @@ def build_html_report(
6768
suggestions=suggestions,
6869
structural_findings=structural_findings,
6970
report_document=report_document,
71+
metrics_diff=metrics_diff,
7072
file_cache=file_cache,
7173
context_lines=context_lines,
7274
max_snippet_lines=max_snippet_lines,
@@ -222,7 +224,7 @@ def _tab_badge(count: int) -> str:
222224
"</div></div>"
223225
)
224226
finding_why_modal_html = (
225-
'<dialog class="modal finding-why-modal" id="finding-why-modal" '
227+
'<dialog class="finding-why-modal" id="finding-why-modal" '
226228
'aria-label="Why This Finding Was Reported">'
227229
'<div class="modal-head">'
228230
"<h2>Why This Finding Was Reported</h2>"
@@ -233,7 +235,7 @@ def _tab_badge(count: int) -> str:
233235
"</dialog>"
234236
)
235237
help_modal_html = (
236-
'<dialog class="modal help-modal" id="help-modal" '
238+
'<dialog class="help-modal" id="help-modal" '
237239
'aria-label="Help & Support">'
238240
'<div class="modal-head">'
239241
"<h2>Help &amp; Support</h2>"

codeclone/_html_report/_components.py

Lines changed: 15 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -35,20 +35,6 @@ def insight_block(*, question: str, answer: str, tone: Tone = "info") -> str:
3535
)
3636

3737

38-
def summary_chip_row(parts: Sequence[str], *, css_class: str) -> str:
39-
cleaned = [str(p).strip() for p in parts if str(p).strip()]
40-
if not cleaned:
41-
return ""
42-
return (
43-
f'<div class="{css_class}">'
44-
+ "".join(
45-
f'<span class="group-explain-item">{_escape_html(p)}</span>'
46-
for p in cleaned
47-
)
48-
+ "</div>"
49-
)
50-
51-
5238
def overview_cluster_header(title: str, subtitle: str | None = None) -> str:
5339
sub = (
5440
f'<p class="overview-cluster-copy">{_escape_html(subtitle)}</p>'
@@ -102,29 +88,30 @@ def overview_row_html(card: Mapping[str, object]) -> str:
10288
category = str(card.get("category", ""))
10389
title = str(card.get("title", ""))
10490
summary_text = str(card.get("summary", ""))
105-
confidence_text = str(card.get("confidence", ""))
10691
location_text = str(card.get("location", ""))
107-
count = _as_int(card.get("count"))
10892
spread = _as_mapping(card.get("spread"))
10993
spread_files = _as_int(spread.get("files"))
11094
spread_functions = _as_int(spread.get("functions"))
11195
clone_type = str(card.get("clone_type", "")).strip()
112-
context_parts = [
96+
97+
# Compact context line: severity · source · category [· clone_type]
98+
ctx_parts = [
11399
severity,
114100
source_kind_label(source_kind),
115101
category.replace("_", " "),
116102
]
117103
if clone_type:
118-
context_parts.append(clone_type)
119-
context_text = " · ".join(p for p in context_parts if p)
120-
stats = summary_chip_row(
121-
(
122-
f"count={count}",
123-
f"spread={spread_functions} fn / {spread_files} files",
124-
f"confidence={confidence_text}",
125-
),
126-
css_class="overview-row-stats",
127-
)
104+
ctx_parts.append(clone_type)
105+
context_text = " \u00b7 ".join(p for p in ctx_parts if p)
106+
107+
# Compact metadata: spread + location on one line
108+
meta_parts: list[str] = []
109+
if spread_files or spread_functions:
110+
meta_parts.append(f"{spread_functions} fn / {spread_files} files")
111+
if location_text:
112+
meta_parts.append(location_text)
113+
meta_text = " \u00b7 ".join(meta_parts)
114+
128115
return (
129116
'<article class="overview-row" '
130117
f'data-severity="{_escape_attr(severity)}" '
@@ -135,8 +122,7 @@ def overview_row_html(card: Mapping[str, object]) -> str:
135122
"</div>"
136123
'<div class="overview-row-side">'
137124
f'<div class="overview-row-context">{_escape_html(context_text)}</div>'
138-
f"{stats}"
139-
f'<div class="overview-row-location">{_escape_html(location_text)}</div>'
125+
f'<div class="overview-row-meta">{_escape_html(meta_text)}</div>'
140126
"</div>"
141127
"</article>"
142128
)

codeclone/_html_report/_context.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,13 @@
1515

1616
if TYPE_CHECKING:
1717
from .._html_snippets import _FileCache
18-
from ..models import GroupItemLike, GroupMapLike, StructuralFindingGroup, Suggestion
18+
from ..models import (
19+
GroupItemLike,
20+
GroupMapLike,
21+
MetricsDiff,
22+
StructuralFindingGroup,
23+
Suggestion,
24+
)
1925

2026
_as_mapping = _coerce.as_mapping
2127
_as_sequence = _coerce.as_sequence
@@ -68,6 +74,9 @@ class ReportContext:
6874
derived_map: Mapping[str, object]
6975
integrity_map: Mapping[str, object]
7076

77+
# -- baseline diff --
78+
metrics_diff: MetricsDiff | None
79+
7180
# -- rendering config --
7281
file_cache: _FileCache
7382
context_lines: int
@@ -152,6 +161,7 @@ def build_context(
152161
suggestions: Sequence[Suggestion] | None = None,
153162
structural_findings: Sequence[StructuralFindingGroup] | None = None,
154163
report_document: Mapping[str, object] | None = None,
164+
metrics_diff: MetricsDiff | None = None,
155165
file_cache: _FileCache,
156166
context_lines: int = 3,
157167
max_snippet_lines: int = 220,
@@ -269,6 +279,7 @@ def build_context(
269279
report_document=report_document_map,
270280
derived_map=derived_map,
271281
integrity_map=integrity_map,
282+
metrics_diff=metrics_diff,
272283
file_cache=file_cache,
273284
context_lines=context_lines,
274285
max_snippet_lines=max_snippet_lines,

codeclone/_html_report/_sections/_clones.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -579,7 +579,8 @@ def render_clones_panel(ctx: ReportContext) -> tuple[str, bool, int, int]:
579579
'<div class="global-novelty-head">'
580580
"<h2>Duplicate Scope</h2>"
581581
'<div class="novelty-tabs" role="tablist" aria-label="Baseline split filter">'
582-
'<button class="btn novelty-tab" type="button" data-global-novelty="new">'
582+
'<button class="btn novelty-tab" type="button" data-global-novelty="new" '
583+
f'data-novelty-state="{"good" if total_new == 0 else "bad"}">'
583584
f'New duplicates <span class="novelty-count">{total_new}</span></button>'
584585
'<button class="btn novelty-tab" type="button" data-global-novelty="known">'
585586
f'Known duplicates <span class="novelty-count">{total_known}</span></button>'

codeclone/_html_report/_sections/_overview.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@
3131
_as_sequence = _coerce.as_sequence
3232

3333

34-
def _health_gauge_html(score: float, grade: str) -> str:
34+
def _health_gauge_html(
35+
score: float, grade: str, *, health_delta: int | None = None
36+
) -> str:
3537
"""Render an SVG ring gauge for health score."""
3638
if score < 0:
3739
return _stat_card(
@@ -48,6 +50,17 @@ def _health_gauge_html(score: float, grade: str) -> str:
4850
color = "var(--warning)"
4951
else:
5052
color = "var(--error)"
53+
54+
delta_html = ""
55+
if health_delta is not None and health_delta != 0:
56+
if health_delta > 0:
57+
cls = "health-ring-delta--up"
58+
sign = "+"
59+
else:
60+
cls = "health-ring-delta--down"
61+
sign = ""
62+
delta_html = f'<div class="health-ring-delta {cls}">{sign}{health_delta}</div>'
63+
5164
return (
5265
'<div class="meta-item overview-health-card">'
5366
'<div class="overview-health-inner">'
@@ -62,6 +75,7 @@ def _health_gauge_html(score: float, grade: str) -> str:
6275
'<div class="health-ring-label">'
6376
f'<div class="health-ring-score">{score:.0f}</div>'
6477
f'<div class="health-ring-grade">Grade {_escape_html(grade)}</div>'
78+
f"{delta_html}"
6579
"</div></div></div></div>"
6680
)
6781

@@ -138,6 +152,14 @@ def _answer_and_tone() -> tuple[str, Tone]:
138152

139153
overview_answer, overview_tone = _answer_and_tone()
140154

155+
# -- MetricsDiff deltas --
156+
md = ctx.metrics_diff
157+
_new_complexity = len(md.new_high_risk_functions) if md else None
158+
_new_coupling = len(md.new_high_coupling_classes) if md else None
159+
_new_dead = len(md.new_dead_code) if md else None
160+
_new_cycles = len(md.new_cycles) if md else None
161+
_health_delta = md.health_delta if md else None
162+
141163
# KPI cards
142164
kpis = [
143165
_stat_card(
@@ -158,6 +180,7 @@ def _answer_and_tone() -> tuple[str, Tone]:
158180
f"max {complexity_summary.get('max', 'n/a')}"
159181
),
160182
tip="Functions with cyclomatic complexity above threshold",
183+
delta_new=_new_complexity,
161184
),
162185
_stat_card(
163186
"High Coupling",
@@ -167,6 +190,7 @@ def _answer_and_tone() -> tuple[str, Tone]:
167190
f"max {coupling_summary.get('max', 'n/a')}"
168191
),
169192
tip="Classes with high coupling between objects (CBO)",
193+
delta_new=_new_coupling,
170194
),
171195
_stat_card(
172196
"Low Cohesion",
@@ -182,12 +206,14 @@ def _answer_and_tone() -> tuple[str, Tone]:
182206
dependency_cycle_count,
183207
detail=f"max depth {dependency_max_depth}",
184208
tip="Circular dependencies between project modules",
209+
delta_new=_new_cycles,
185210
),
186211
_stat_card(
187212
"Dead Code",
188213
dead_total,
189214
detail=f"{dead_high_conf} high-confidence",
190215
tip="Potentially unused functions, classes, or imports",
216+
delta_new=_new_dead,
191217
),
192218
]
193219

@@ -219,7 +245,9 @@ def _answer_and_tone() -> tuple[str, Tone]:
219245
+ "</div></section>"
220246
)
221247

222-
health_gauge = _health_gauge_html(health_score, health_grade)
248+
health_gauge = _health_gauge_html(
249+
health_score, health_grade, health_delta=_health_delta
250+
)
223251

224252
return (
225253
insight_block(

0 commit comments

Comments
 (0)