diff --git a/CHANGELOG.md b/CHANGELOG.md
index 994c3377..a8850a65 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -703,3 +703,11 @@ Added:
- Added clearer export history provenance with formatted export-type badges, analysis-run ids and created timestamps when available.
- Added regression coverage for the Export Center handoff structure and responsive styling.
- No API contracts, migrations, product capabilities, live provider fetching or AI/model dependency changes were introduced.
+
+## Sprint 65 project report readability polish (2026-06-18)
+
+- Reworked the lightweight HTML project report template into a self-contained handoff layout with hero, readiness pill, scorecards and sectioned tables.
+- Added print-friendly CSS and scroll-safe table wrappers while preserving the existing `project_report_html` export type and download behavior.
+- Added source/CRS columns to the dataset inventory section and clearer "Dataset inventory", "QA/QC evidence" and "Artifact history" report headings.
+- Added regression coverage for report layout markers, print CSS and HTML escaping.
+- No API contracts, migrations, product capabilities, PDF/report-designer functionality, live provider fetching or AI/model dependency changes were introduced.
diff --git a/backend/README.md b/backend/README.md
index 066c6782..ec4a66e7 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -563,6 +563,17 @@ If `rasterio` is unavailable:
- raster metadata responses return `503` with `RASTER_PROCESSING_UNAVAILABLE`
- raster clip/tile endpoints return explicit unavailable responses
+## Export report artifact
+
+`POST /api/v1/exports/report` creates the existing lightweight
+`project_report_html` artifact. The report is a self-contained HTML handoff
+view rendered from persisted project, dataset, QA/QC and export-history state.
+It includes readiness scorecards, dataset inventory, QA/QC evidence, artifact
+history, known limitations and print-friendly CSS.
+
+This remains a simple HTML export. It does not add a PDF designer, report
+builder, live provider fetching or new analysis behavior.
+
## Helpful repository scripts
- `bash scripts/backend_install.sh`
diff --git a/backend/app/services/export_service.py b/backend/app/services/export_service.py
index bd039d0d..2e21a95b 100644
--- a/backend/app/services/export_service.py
+++ b/backend/app/services/export_service.py
@@ -437,10 +437,28 @@ class ExportService:
exports = summary["exports"]
readiness_summary = summary["readiness_summary"]
known_limitations = summary["known_limitations"]
+ counts = readiness_summary["counts"]
+ overall_state = str(readiness_summary["overall_state"])
+ overall_state_class = ExportService._html_class_token(overall_state)
+ generated_context = "Generated from persisted GeoIntel state"
+ scorecards = [
+ ("Areas", counts.get("area_count", 0)),
+ ("Datasets", f"{counts.get('ready_dataset_count', 0)}/{counts.get('dataset_count', 0)} ready"),
+ ("Reference", counts.get("reference_dataset_count", 0)),
+ ("QA/QC", counts.get("quality_check_count", 0)),
+ ("Exports", counts.get("export_count", 0)),
+ ]
+ scorecard_html = "\n".join(
+ "
"
+ f"{escape(str(label))}"
+ f"{escape(str(value))}"
+ "
"
+ for label, value in scorecards
+ )
readiness_rows = "\n".join(
""
f"| {escape(str(item['label']))} | "
- f"{escape(str(item['state']))} | "
+ f"{escape(str(item['state']))} | "
f"{escape(str(item['detail']))} | "
"
"
for item in readiness_summary["items"]
@@ -453,6 +471,8 @@ class ExportService:
f"{escape(str(item['dataset_role']))} | "
f"{escape(str(item['status']))} | "
f"{escape(str(item['feature_count'] if item['feature_count'] is not None else 'n/a'))} | "
+ f"{escape(str(item.get('source_name') or 'n/a'))} | "
+ f"{escape(str(item.get('crs') or 'n/a'))} | "
""
for item in datasets
)
@@ -477,51 +497,198 @@ class ExportService:
+
GeoIntel Project Report - {escape(str(project["name"]))}
- {escape(str(project["name"]))}
- GeoIntel project report artifact
- Region: {escape(str(project["region"]))}
- Status: {escape(str(project["status"]))}
- Description: {escape(str(project["description"] or "n/a"))}
- V1 Readiness Summary
- Overall state: {escape(str(readiness_summary["overall_state"]))}
-
- | Area | State | Detail |
- {readiness_rows}
-
- Datasets ({len(datasets)})
-
- | Name | Type | Role | Status | Features |
- {dataset_rows or '| No datasets |
'}
-
- QA/QC Results ({len(quality_checks)})
-
- | Check | Status | Score | Reference dataset |
- {quality_rows or '| No QA/QC results |
'}
-
- Export History ({len(exports)})
-
- | Type | Storage path | Created |
- {export_rows or '| No exports |
'}
-
- Known Limitations
-
+
+
+
+
GeoIntel project report artifact
+
{escape(str(project["name"]))}
+
{generated_context}
+
Region: {escape(str(project["region"]))} ยท Status: {escape(str(project["status"]))}
+
Description: {escape(str(project["description"] or "n/a"))}
+
+ {escape(overall_state)}
+
+ {scorecard_html}
+
+ Release handoff
+ V1 Readiness Summary
+ Overall state: {escape(overall_state)}
+
+
+ | Area | State | Detail |
+ {readiness_rows}
+
+
+
+
+ Data handoff
+ Dataset inventory ({len(datasets)})
+
+
+ | Name | Type | Role | Status | Features | Source | CRS |
+ {dataset_rows or '| No datasets |
'}
+
+
+
+
+ Quality handoff
+ QA/QC evidence ({len(quality_checks)})
+
+
+ | Check | Status | Score | Reference dataset |
+ {quality_rows or '| No QA/QC results |
'}
+
+
+
+
+ Artifact handoff
+ Artifact history ({len(exports)})
+ Export History ({len(exports)})
+
+
+ | Type | Storage path | Created |
+ {export_rows or '| No exports |
'}
+
+
+
+
+ Scope guardrails
+ Known Limitations
+
+
+
"""
+ @staticmethod
+ def _html_class_token(value: str) -> str:
+ token = re.sub(r"[^a-zA-Z0-9_-]+", "_", value.strip().lower()).strip("_")
+ return token or "unknown"
+
@staticmethod
def _create_response(export: Export) -> ExportCreateResponse:
return ExportCreateResponse(
diff --git a/backend/tests/test_sprint65_project_report_polish.py b/backend/tests/test_sprint65_project_report_polish.py
new file mode 100644
index 00000000..3a77a5bc
--- /dev/null
+++ b/backend/tests/test_sprint65_project_report_polish.py
@@ -0,0 +1,125 @@
+from __future__ import annotations
+
+from app.services.export_service import ExportService
+
+
+def test_project_report_html_uses_handoff_layout_and_print_styles() -> None:
+ summary = {
+ "project": {
+ "id": "project-1",
+ "name": "Demo ",
+ "description": "QA handoff",
+ "region": "Kempen",
+ "status": "active",
+ },
+ "datasets": [
+ {
+ "id": "dataset-1",
+ "name": "reference.geojson",
+ "dataset_type": "vector",
+ "dataset_role": "reference",
+ "source_name": "fixture",
+ "reference_layer_name": "buildings",
+ "status": "ready",
+ "crs": "EPSG:4326",
+ "bounds_json": None,
+ "feature_count": 2,
+ }
+ ],
+ "quality_checks": [
+ {
+ "id": "quality-1",
+ "analysis_run_id": None,
+ "candidate_dataset_id": "candidate-1",
+ "reference_dataset_id": "dataset-1",
+ "check_type": "demo_candidate_vs_reference",
+ "status": "ok",
+ "score": 0.75,
+ }
+ ],
+ "exports": [
+ {
+ "id": "export-1",
+ "analysis_run_id": None,
+ "export_type": "project_metadata_json",
+ "storage_path": "storage/exports/metadata.json",
+ "metadata_json": {"readiness_state": "ready"},
+ "created_at": "2026-06-18T10:00:00+00:00",
+ }
+ ],
+ "readiness_summary": {
+ "overall_state": "ready",
+ "items": [
+ {"key": "project", "label": "Project", "state": "ready", "detail": "Demo (Kempen)"},
+ {"key": "datasets", "label": "Datasets", "state": "ready", "detail": "1/1 ready"},
+ ],
+ "counts": {
+ "area_count": 1,
+ "dataset_count": 1,
+ "ready_dataset_count": 1,
+ "vector_dataset_count": 1,
+ "raster_dataset_count": 0,
+ "reference_dataset_count": 1,
+ "quality_check_count": 1,
+ "export_count": 1,
+ },
+ },
+ "known_limitations": ["No live GRB/OSM/Sentinel fetching is performed by the report export."],
+ }
+
+ html = ExportService._render_project_report_html(summary)
+
+ assert 'class="report-shell"' in html
+ assert 'class="report-hero"' in html
+ assert 'class="report-scorecards"' in html
+ assert 'class="readiness-pill readiness-ready"' in html
+ assert 'class="section-kicker"' in html
+ assert "@media print" in html
+ assert "page-break-inside: avoid" in html
+ assert "Generated from persisted GeoIntel state" in html
+ assert "Dataset inventory" in html
+ assert "QA/QC evidence" in html
+ assert "Artifact history" in html
+ assert "Demo <Kempen>" in html
+
+
+def test_project_report_html_escapes_table_values_in_polished_layout() -> None:
+ summary = {
+ "project": {
+ "id": "project-1",
+ "name": "",
+ "description": "unsafe",
+ "region": "Kempen",
+ "status": "active",
+ },
+ "datasets": [],
+ "quality_checks": [],
+ "exports": [],
+ "readiness_summary": {
+ "overall_state": "needs_attention",
+ "items": [
+ {"key": "project", "label": "", "state": "waiting", "detail": ""},
+ ],
+ "counts": {
+ "area_count": 0,
+ "dataset_count": 0,
+ "ready_dataset_count": 0,
+ "vector_dataset_count": 0,
+ "raster_dataset_count": 0,
+ "reference_dataset_count": 0,
+ "quality_check_count": 0,
+ "export_count": 0,
+ },
+ },
+ "known_limitations": [""],
+ }
+
+ html = ExportService._render_project_report_html(summary)
+
+ assert "" not in html
+ assert "<script>alert(1)</script>" in html
+ assert "unsafe" not in html
+ assert "<b>unsafe</b>" in html
+ assert "<Project>" in html
+ assert "<unsafe limitation>" in html
+ assert 'class="readiness-pill readiness-needs_attention"' in html
diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md
index 91ffa491..4fabea30 100644
--- a/docs/CODEX_EXECUTION_LOG.md
+++ b/docs/CODEX_EXECUTION_LOG.md
@@ -2489,3 +2489,30 @@ Limitations:
Next recommended pass:
- Run a live browser smoke across Exports and Overview after deployment, then continue with report artifact readability if the exported HTML itself needs visual polish.
+
+## Sprint 65 Project report readability polish (2026-06-18)
+
+Changed:
+- Reworked the lightweight `project_report_html` renderer into a self-contained handoff layout with hero, readiness pill, scorecards and sectioned report content.
+- Added print-friendly CSS and scroll-safe table wrappers to the HTML artifact.
+- Added source and CRS columns to the dataset inventory section.
+- Preserved existing export type, endpoint behavior, download behavior and storage flow.
+- Added `backend/tests/test_sprint65_project_report_polish.py`.
+- Updated `backend/README.md`, `docs/TODO.md` and `CHANGELOG.md`.
+
+Tested:
+- Red step: `python -m pytest backend/tests/test_sprint65_project_report_polish.py -q` failed on missing report shell, scorecards, print styles and readiness pill classes.
+- `python -m pytest backend/tests/test_sprint65_project_report_polish.py backend/tests/test_sprint17_export_foundation.py -q` (`12 passed`)
+- `python -m compileall backend/app`
+- `cd frontend && npm run typecheck`
+- `cd frontend && npm run build`
+- `bash scripts/run_readiness_check.sh` (`235 passed`)
+
+Open:
+- Run full readiness, deploy Tower and smoke the HTML report download visually.
+
+Limitations:
+- This remains a lightweight HTML handoff artifact. It does not add PDF generation, a report designer, new endpoints, live provider fetching or AI/model behavior.
+
+Next recommended pass:
+- Verify the generated report artifact through the live export workflow, then continue with a full-workspace browser smoke if the runtime stays green.
diff --git a/docs/TODO.md b/docs/TODO.md
index cee7a964..e64c5c50 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -337,3 +337,4 @@ This file now starts with the current implementation status. Older preparation/b
- [x] Add workbench visual polish pass for command bar, panel surfaces, empty states and mobile nav density.
- [x] Add map/result overlay ergonomics for active layer provenance and feature property summaries.
- [x] Add export/report handoff polish for artifact readiness, action grouping and export provenance.
+- [x] Polish lightweight HTML project report readability, print styling and handoff sections.