diff --git a/AGENTS.md b/AGENTS.md
index e6c39fe..58cc049 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -11,6 +11,8 @@ in `out/` is served by any static host (GitHub Pages, GitLab Pages, Bitbucket).
**Key Features:**
- Search over curated repository listing (`repositories.txt`)
- Reports rendered with Mustache.js (bundled template is authoritative)
+- Enhanced report tables: sticky headers, pagination (>20 rows), sortable
+ columns, in-table search, CSV export, click/keyboard cell copy with toasts
- Repository submission via pre-filled platform issues — no login, apps or tokens
- Fully static deploy; deep links handled via generateStaticParams + `404.html`
@@ -60,12 +62,17 @@ app/ # Next.js App Router (static export)
components/ # client components: report-view, repo-list,
# repo-submission-form, search-combobox,
# hero-search, menu-search, menu-toggle,
- # workflow-sample, platform-config, sentry-provider
+ # workflow-sample, platform-config, sentry-provider,
+ # toast-notification (copy feedback live region)
lib/ # shared logic (client + RSC): routes, fetcher,
# renderer, search, utils, host, rate-limiter,
# cache-manager, error-handler, repo-submission,
- # report-view, static-params, widget-loader; the
- # Node-side listing loader is lib/repositories.js
+ # report-view, static-params, widget-loader,
+ # table-operations (filter/sort/paginate/CSV/copy +
+ # TABLE_CONFIG + REPORT_TABLES descriptors),
+ # table-enhancer (binds toolbar/sort/pagination/
+ # copy onto the rendered report DOM); the Node-side
+ # listing loader is lib/repositories.js
public/ # static files copied verbatim into out/:
repositories.txt # synced from the repo root (sync-repositories.mjs)
assets/ # mustache template, logo
@@ -117,12 +124,54 @@ coverage there when introducing new markup patterns.
`sentry-dsn`, `platform-base-url`) plus `NEXT_PUBLIC_HOSTING_ENVIRONMENT`
/ `NEXT_PUBLIC_BASE_PATH` at build time.
+## Report Tables (Enhanced)
+
+- Every data table in the report (class/package relationships, disharmony
+ findings, cycle summary, cycle breakdown) is enhanced: sticky `thead th`,
+ toolbar (match live region + copy hint left; search + CSV export right —
+ the `.rf-table-block` wrapper shrink-wraps the table and the toolbar uses
+ `contain: inline-size` so controls align with the table's right edge),
+ sortable th buttons with `aria-sort`, pagination below 20+ row tables, and
+ click/Enter/Space cell copy with toast feedback. The filter's clear control
+ is an × button (accessible name "Clear the … table filter").
+- Horizontal scrollbar: tables wider than the viewport get `overflow-x: auto`
+ via the `rf-scroll-x-enabled` class, toggled by `lib/table-enhancer.js`
+ after measuring `wrapper.scrollWidth > clientWidth` (re-measured on each
+ re-render and on window resize). It MUST stay conditional — any overflow
+ ancestor becomes the sticky constraint container and breaks the
+ viewport-sticky `thead th`. Scrolling tables keep their header pinned
+ anyway: `refreshStickyHeaders` in lib/table-enhancer.js compensates by
+ translating every `thead th` down by the viewport scroll offset (clamped to
+ the table's bottom edge) on window scroll/resize; narrow tables keep pure
+ CSS stickiness and stale transforms are cleared when overflow goes away.
+- Pipeline: `prepareReportData(data, tableStates, TABLE_CONFIG)` in
+ lib/renderer.js applies **filter → sort → paginate** per table and injects
+ `tableUi` blocks the mustache template renders; `enhanceTables` in
+ lib/table-enhancer.js binds the controls and reports state changes back to
+ components/report-view.jsx, which re-renders (widgets only gate the first
+ render; the search input's focus/caret is restored after each re-render).
+ The expensive `enhanceReport` pipeline (Chart.js charts, WASM DOT layout)
+ runs only when the payload changes — table-state re-renders stash the live
+ chart canvases / graph containers before the innerHTML swap and graft them
+ back into the fresh DOM (`statefulElementIds`/`stashStatefulDom`/
+ `graftStatefulDom` in lib/report-view.js), re-binding only the cheap popup
+ handlers.
+- Search ``s are injected by table-enhancer — `` is FORBID in
+ the renderer's sanitization allow-list, so it must never appear in the
+ mustache template.
+- CSV export honors the current filter + sort but ignores pagination;
+ filenames are `refactorfirst-
-.csv`.
+
## Current Test Count
-~313 unit/integration + 112 E2E (three browsers + basePath leg).
+~485 unit/integration + 175 E2E (171 across three browsers + 4 basePath leg).
WCAG 2.2 AA / HTML5 guards live in tests/unit/html5-attributes.test.js,
tests/unit/report-template-wcag.test.js, tests/unit/css-a11y.test.js and
tests/unit/page-titles.test.js — the report mustache keeps a single h1,
scoped table headers, captions, labelled canvases and a named nav; obsolete
-presentational attributes are FORBID_ATTR-stripped in lib/renderer.js.
+presentational attributes are FORBID_ATTR-stripped in lib/renderer.js. The
+report-template-wcag guard also asserts sticky-header CSS, per-table
+toolbars/aria-labelled export buttons and pagination navs, valid `aria-sort`
+on every enhanced th, sortable keyboard-operable header buttons and live
+match-count regions.
diff --git a/README.md b/README.md
index a6db81c..b7839e7 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,8 @@ is a **Next.js static export** (`bun run build` produces `out/`, which any stati
branch fallback — the same report the
[RefactorFirst report viewer](https://github.com/RefactorFirst/RefactorFirst) produces:
class/package maps (vizdom WASM SVGs with pan/zoom, plus Sigma 2D and 3D force-graph popups), relationship-removal
- priority tables, Chart.js disharmony bubble charts and class cycle summaries
+ priority tables, Chart.js disharmony bubble charts and class cycle summaries — with
+ [enhanced tables](#enhanced-report-tables) (sticky headers, pagination, sorting, search, CSV export, copy)
- **Repository submission** via a pre-filled issue on the hosting platform (no login, apps or tokens on this site): your
platform account is captured as the issue author and validated server-side by the platform's CI
- Reports and submissions work for repositories hosted on the same platform as the deployment (GitHub, GitLab or
@@ -29,6 +30,7 @@ is a **Next.js static export** (`bun run build` produces `out/`, which any stati
- [Deploying to Bitbucket](#deploying-to-bitbucket)
- [Deploying to GitLab](#deploying-to-gitlab)
- [How repository submission works](#how-repository-submission-works)
+- [Enhanced report tables](#enhanced-report-tables)
- [Making Changes (Developer Guide)](#making-changes-developer-guide)
- [Testing](#testing)
@@ -289,6 +291,37 @@ GitLab Pages deploys from the `pages` job and serves
---
+## Enhanced report tables
+
+Large report tables (class/package relationships, disharmony findings, cycle summaries and cycle breakdowns) are
+interactive — all WCAG 2.2 AA and keyboard-operable, with no extra dependencies:
+
+- **Sticky headers** — column headers stay pinned to the top of the viewport while you scroll a table.
+- **Pagination** — tables with more than 20 matching rows paginate at 20 rows per page ("Page X of Y" + Previous/Next
+ buttons; disabled on the first/last page). Small tables render in full without controls.
+- **Sorting** — click a column header (or focus it and press Enter/Space) to sort the whole table ascending; click
+ again for descending. `aria-sort` reflects the current direction, sorting happens before pagination, and the sort is
+ kept while navigating pages.
+- **Search/filter** — the filter box sits at the right edge of the table (next to Export) and narrows rows
+ case-insensitively across all columns (debounced), with the match count announced via a live region and an **×**
+ button to reset.
+- **CSV export** — "Export CSV" downloads the entire table (current filter and sort applied, pagination ignored) with
+ proper escaping; the filename includes the table name and a timestamp.
+- **Horizontal scrolling** — when a table is wider than the screen its wrapper gains a horizontal scrollbar
+ (`rf-scroll-x-enabled`, toggled by measurement in `lib/table-enhancer.js`); the scrollbar is deliberately conditional
+ because an unconditional `overflow-x` would break the viewport-sticky table header. Scrolling tables keep their
+ header pinned anyway: `lib/table-enhancer.js` translates the header cells down by the viewport scroll offset
+ (clamped to the table's bottom edge) on every window scroll/resize.
+- **Copy cells** — click any cell (or focus it and press Enter/Space) to copy its text; an auto-dismissing toast
+ confirms the copy. Falls back gracefully when the Clipboard API is unavailable.
+
+Implementation: pure table operations live in `lib/table-operations.js` (filter → sort → paginate pipeline, CSV
+generation, clipboard helper, `TABLE_CONFIG` defaults: threshold/page size 20, 300 ms search debounce, 3 s
+toast duration), the rendered DOM is wired by `lib/table-enhancer.js`, and table state re-renders through
+`prepareReportData` in `lib/renderer.js`.
+
+---
+
## How repository submission works
No OAuth app, client ID, token or secret is involved on the client side — forks need **zero auth setup**. The flow on
diff --git a/app/globals.css b/app/globals.css
index 8f3b9ab..c99f19a 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -461,3 +461,50 @@ pre {
grid-template-columns: 1fr;
}
}
+
+/* Toast notifications (copy feedback on report tables). Rendered by
+ components/toast-notification.jsx as a persistent aria-live region. */
+.rf-toast-region {
+ position: fixed;
+ left: 50%;
+ bottom: 1.25rem;
+ transform: translateX(-50%);
+ z-index: 2000;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ align-items: center;
+ pointer-events: none;
+ max-width: min(90vw, 40rem);
+}
+
+.rf-toast {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.6rem 0.9rem;
+ border-radius: var(--radius);
+ background: #1f2933;
+ color: #fff;
+ box-shadow: var(--shadow);
+ pointer-events: auto;
+}
+
+.rf-toast-message {
+ overflow-wrap: anywhere;
+}
+
+.rf-toast-dismiss {
+ background: none;
+ border: none;
+ color: #fff;
+ font-size: 1.1rem;
+ line-height: 1;
+ cursor: pointer;
+ padding: 0.15rem 0.45rem;
+}
+
+.rf-toast-dismiss:focus-visible {
+ outline: 2px solid var(--brand-color);
+ outline-offset: 2px;
+}
diff --git a/assets/refactor-first-report.mustache b/assets/refactor-first-report.mustache
index 4043571..2d075d5 100644
--- a/assets/refactor-first-report.mustache
+++ b/assets/refactor-first-report.mustache
@@ -89,6 +89,10 @@
border: 5px solid;
border-collapse: collapse;
margin: 0 auto;
+ /* mvp.css gives every table overflow-x: auto, which would make the
+ table itself the scroll container and prevent the sticky header
+ from pinning to the viewport while the page scrolls. */
+ overflow: visible;
}
.rf-data-table th,
@@ -96,6 +100,216 @@
border: 1px solid;
padding: 2px 6px;
}
+
+ /* Enhanced tables: sticky headers pinned to the viewport while their
+ table scrolls, plus sortable-header, toolbar, pagination and copy
+ affordances (plan: implement-paginated-tables-with-sticky-headers). */
+ .rf-data-table thead th {
+ position: sticky;
+ top: 0;
+ z-index: 5;
+ /* A solid, contrasting background keeps scrolled rows from showing
+ through and keeps the header text readable (the base stylesheet
+ colours table header text near-white); the inset shadow restores
+ the bottom border that border-collapse drops from sticky cells. */
+ background: #2a6f97;
+ color: #fff;
+ box-shadow: inset 0 -1px 0 #1f5273;
+ }
+
+ .rf-sort-btn {
+ background: none;
+ border: none;
+ padding: 0;
+ font: inherit;
+ color: inherit;
+ cursor: pointer;
+ }
+
+ .rf-sort-btn:hover,
+ .rf-sort-btn:focus-visible {
+ text-decoration: underline;
+ }
+
+ .rf-sort-btn:focus-visible {
+ outline: 2px solid #fff;
+ outline-offset: 2px;
+ }
+
+ .rf-sort-indicator {
+ display: inline-block;
+ min-width: 1em;
+ margin-left: 0.25rem;
+ }
+
+ /* The block shrink-wraps the table (or clamps to the available width),
+ so the toolbar and pagination align with the table edges. */
+ .rf-table-block {
+ width: fit-content;
+ max-width: 100%;
+ margin-left: auto;
+ margin-right: auto;
+ }
+
+ .rf-table-toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5rem;
+ margin: 0.5rem 0;
+ /* Excluded from the shrink-wrap sizing of .rf-table-block so the
+ toolbar always spans the table's width, never wider. */
+ contain: inline-size;
+ }
+
+ /* Search + export hug the right edge of the table; status text and the
+ copy hint stay left. */
+ .rf-table-actions {
+ margin-left: auto;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 0.5rem;
+ }
+
+ /* The outer 5px frame moves from the table to the scroll wrapper:
+ when the table overflows and scrolls horizontally, a border on the
+ table itself would scroll away with the content instead of boxing
+ the visible area. Unwrapped tables (problem/solution) keep their
+ own border.
+ Horizontal scrollbar appears only when the table overflows: adding
+ overflow unconditionally would make the wrapper a scroll container
+ and break the viewport-sticky table header, so
+ lib/table-enhancer.js toggles .rf-scroll-x-enabled after measuring —
+ and pins the header of scrolling tables anyway by translating the
+ header cells with the viewport scroll offset. */
+ .rf-table-scroll {
+ max-width: 100%;
+ border: 5px solid;
+ box-sizing: border-box;
+ }
+
+ .rf-table-scroll .rf-data-table {
+ border: none;
+ }
+
+ .rf-table-scroll.rf-scroll-x-enabled {
+ overflow-x: auto;
+ }
+
+ .rf-table-toolbar .rf-copy-hint {
+ font-size: 0.85rem;
+ margin: 0;
+ }
+
+ /* Keep the label, text box and clear button on one centred line — as
+ plain inline siblings their boxes would sit on a shared baseline and
+ visibly misalign vertically. */
+ .rf-table-search {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ }
+
+ .rf-table-search label {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ }
+
+ .rf-table-search input {
+ padding: 0.3rem 0.5rem;
+ min-width: 14rem;
+ }
+
+ .rf-table-search .rf-search-clear {
+ padding: 0.2rem 0.45rem;
+ cursor: pointer;
+ font-size: 1rem;
+ line-height: 1;
+ font-weight: bold;
+ }
+
+ .rf-table-match {
+ font-size: 0.9rem;
+ }
+
+ .rf-export-btn {
+ padding: 0.3rem 0.75rem;
+ border: 1px solid #2a6f97;
+ border-radius: 6px;
+ background: #2a6f97;
+ color: #fff;
+ cursor: pointer;
+ }
+
+ .rf-export-btn:focus-visible,
+ .rf-page-btn:focus-visible {
+ outline: 2px solid #1f5273;
+ outline-offset: 2px;
+ }
+
+ .rf-table-pagination {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1rem;
+ align-items: center;
+ justify-content: center;
+ margin: 0.75rem 0;
+ contain: inline-size;
+ }
+
+ .rf-page-btn {
+ padding: 0.35rem 0.75rem;
+ /* Explicit colours on both states: the base stylesheet gives plain
+ buttons near-white text, which would be invisible on a white
+ background. */
+ border: 1px solid #2a6f97;
+ border-radius: 6px;
+ background: #fff;
+ color: #1f2937;
+ cursor: pointer;
+ }
+
+ .rf-page-btn:hover:not([disabled]) {
+ background: #e8f0f6;
+ }
+
+ .rf-page-btn[disabled] {
+ background: #f3f4f6;
+ border-color: #c6d2dc;
+ color: #6b7280;
+ cursor: default;
+ }
+
+ .rf-data-table td[data-rf-copy] {
+ cursor: copy;
+ }
+
+ .rf-data-table td[data-rf-copy]:focus-visible {
+ outline: 2px solid #2a6f97;
+ outline-offset: -2px;
+ }
+
+ @media (max-width: 640px) {
+ .rf-table-toolbar {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .rf-table-actions {
+ align-self: stretch;
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .rf-table-search input {
+ width: 100%;
+ min-width: 0;
+ box-sizing: border-box;
+ }
+ }
@@ -221,17 +435,35 @@
Classes with * should be broken apart
Removing class relationships below will eliminate class cycles
+ {{classRelationshipsToRemove.tableUi.matchStatus}}
+ {{#classRelationshipsToRemove.tableUi.copyable}}
+ Click a cell (or press Enter on it) to copy its text.
+ {{/classRelationshipsToRemove.tableUi.copyable}}
+
{{/classRelationshipsToRemove.hasRelationships}}
@@ -306,16 +546,34 @@
Packages and classes with * should be broken apart
Removing package relationships below will eliminate package cycles
-
+ {{packageRelationshipsToRemove.tableUi.matchStatus}}
+ {{#packageRelationshipsToRemove.tableUi.copyable}}
+ Click a cell (or press Enter on it) to copy its text.
+ {{/packageRelationshipsToRemove.tableUi.copyable}}
+
+ {{classCycles.summaryUi.matchStatus}}
+ {{#classCycles.summaryUi.copyable}}
+ Click a cell (or press Enter on it) to copy its text.
+ {{/classCycles.summaryUi.copyable}}
+
+ {{classCycles.largestCycle.breakdownUi.matchStatus}}
+ {{#classCycles.largestCycle.breakdownUi.copyable}}
+ Click a cell (or press Enter on it) to copy its text.
+ {{/classCycles.largestCycle.breakdownUi.copyable}}
+