feat: make the dashboard readable at a glance

The dashboard showed the anonymize date under "Expired", a dash in the
Expires column for 54 of 60 rows, raw status codes such as
branch_not_found, and a chip reading "REMOVED" that actually meant
"removed items are hidden". Muted text sat at 3.5:1 contrast, keyboard
focus was invisible, numbers were left-aligned, and a legacy record
without an identifier rendered as an empty link to /pr/undefined/.

This rewrites the dashboard template and controller and tightens the
theme tokens:

- Status sub-line is labelled ("anonymized on Sep 6, 2026"); Expires
  shows Never, the date, or "Expired on <date>".
- Conference folded into the name cell as a tag; Views right-aligned;
  every column header is a keyboard-sortable button with aria-sort.
- Sort/Status buttons show their state; chips read "Hiding Removed";
  result count and Clear filters; empty state with a way out.
- Status filter covers Ready, In progress, Error, Expired, Removed.
  Stuck downloads say so; codes map to sentences; statuses to labels.
- Lists load in parallel and merge once behind a skeleton; broken
  records are flagged instead of linking nowhere.
- Actions menu: View, View page, Edit, Force update, divider, Remove,
  all as buttons rather than href="#" anchors.
- Theme: warm dark canvas, muted text >= 4.5:1 in both modes, semantic
  status tokens, 11/13/14/16px type scale, mono only for identifiers,
  6px/10px/pill radii, :focus-visible ring, quota bars in ink until a
  limit is near, unlimited shown as such.
- Mobile: no 9px text, filter row wraps in two lines, meta on one line.

Adds scripts/dev-mock.js to serve the dashboard with fixture data and
test/dashboard-ui.test.js covering the filters, template invariants and
token contrast.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
tdurieux
2026-09-08 20:20:17 +02:00
co-authored by Claude Fable 5.1
parent 9f00641529
commit 8e9b5b9a45
8 changed files with 1157 additions and 325 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
{
"core.min.js": "core.6332b3c288.min.js",
"vendor.min.js": "vendor.bc507e5015.min.js",
"vendor.min.js": "vendor.6cf1035e61.min.js",
"mermaid.min.js": "mermaid.f848a72d16.min.js",
"all.min.css": "all.99ce8f3e14.min.css"
}
"all.min.css": "all.8084ca12ce.min.css"
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+332 -67
View File
@@ -3,10 +3,11 @@
Serif display (Instrument Serif), Inter body, JetBrains Mono. */
.dark-mode {
/* Warm charcoal — softened from pure black */
--canvas-bg-color: #1f1f1f;
/* Warm ink canvas — same temperature as the light paper palette so
cards (#25231F) and borders (#33302B) sit on a matching base. */
--canvas-bg-color: #1B1916;
--sidebar-bg-color: #1A1918;
--main-bg-color: #1f1f1f;
--main-bg-color: #1B1916;
--header-bg-color: #1A1918;
--hover-bg-color: rgba(255, 255, 255, 0.05);
@@ -29,9 +30,20 @@
--accent: #A7B2FF;
--accent-soft: rgba(167, 178, 255, 0.12);
--ink-muted: #8A857C;
/* --ink-muted is used for small text: 6.5:1 on the canvas.
--ink-faint is for non-text (icons, rules, dots), never for copy. */
--ink-muted: #A39D92;
--ink-faint: #8A857C;
--ink-soft: #B5AFA2;
/* Semantic status colours (text/dot/border on canvas ≥ 4.5:1) */
--status-ready: #5FB27A;
--status-progress: #E0A94A;
--status-error: #F08A82;
--status-inactive: #8A857C;
--status-error-soft: rgba(240, 138, 130, 0.08);
--status-error-soft-hover: rgba(240, 138, 130, 0.14);
--admin-nav-bg: #1A1918;
--admin-nav-active: rgba(167, 178, 255, 0.12);
--admin-stat-bg: #25231F;
@@ -70,12 +82,27 @@ body {
--accent: #3B4AD6;
--accent-soft: #EEF0FF;
--ink-muted: #8A857C;
/* --ink-muted is used for small text: 5.1:1 on the paper canvas.
--ink-faint is for non-text (icons, rules, dots), never for copy. */
--ink-muted: #6F6A61;
--ink-faint: #8A857C;
--ink-soft: #4A4641;
--paper-bg-alt: #F3F1EC;
--paper-card: #FFFFFF;
--highlight: #FFF3C4;
/* Semantic status colours (text/dot/border on canvas ≥ 4.5:1) */
--status-ready: #2F7A44;
--status-progress: #946512;
--status-error: #B42318;
--status-inactive: #8A857C;
--status-error-soft: rgba(180, 35, 24, 0.04);
--status-error-soft-hover: rgba(180, 35, 24, 0.08);
/* Two-step radius system: controls and surfaces. Chips/badges are pills. */
--radius-control: 6px;
--radius-surface: 10px;
--font-serif: 'Instrument Serif', 'Iowan Old Style', Georgia, serif;
--font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
@@ -106,19 +133,31 @@ body {
}
}
/* Keyboard focus: a visible 2px accent ring only when focus came from the
keyboard (:focus-visible); pointer clicks get no ring. The ring is ≥ 3:1
against both canvases (indigo 6.4:1 light, periwinkle 8.7:1 dark). */
.btn:focus,
.btn:active {
outline: none !important;
box-shadow: none;
}
textarea,
select,
input,
button {
:focus:not(:focus-visible) {
outline: none;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.btn:focus-visible,
.form-control:focus-visible,
.dropdown-item:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
box-shadow: none;
}
input:hover,
input:active {
box-shadow: none !important;
@@ -1898,9 +1937,13 @@ code {
opacity: 0.7;
cursor: pointer;
padding: 0;
margin-left: 2px;
/* 24px tap target; negative margin keeps the chip visually compact */
min-width: 24px;
min-height: 24px;
margin: -6px -6px -6px 0;
font-size: 1rem;
line-height: 1;
border-radius: 999px;
}
.filter-chip-close:hover {
@@ -2025,8 +2068,8 @@ code {
.type-badge {
font-family: var(--font-mono);
letter-spacing: 0.06em;
padding: 2px 7px;
border-radius: 3px;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--border-color);
background: var(--paper-bg-alt, transparent);
}
@@ -3898,7 +3941,7 @@ code {
.paper-crumbs {
font-family: var(--font-mono);
font-size: 11.5px;
font-size: 11px;
color: var(--ink-muted);
letter-spacing: 0.02em;
}
@@ -4161,6 +4204,12 @@ textarea::selection {
height: 38px;
background: var(--paper-card);
}
.paper-page .dashboard-filter-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.paper-page .dashboard-filter-row .dropdown-toggle,
.paper-page .dashboard-filter-row .btn-group .btn {
background: var(--paper-card);
@@ -4170,12 +4219,30 @@ textarea::selection {
padding: 0 14px;
font-size: 14px;
font-weight: 500;
border-radius: 6px;
border-radius: var(--radius-control, 6px);
display: inline-flex;
align-items: center;
gap: 6px;
line-height: 1;
}
/* "Sort · Name ↓" style toggles: the label is muted, the value is ink so the
current state reads at a glance. */
.paper-page .dashboard-filter-row .dropdown-toggle .filter-btn-label {
color: var(--ink-muted);
font-weight: 500;
}
.paper-page .dashboard-filter-row .dropdown-toggle .filter-btn-label::after {
content: "·";
margin-left: 6px;
color: var(--ink-faint);
}
.paper-page .dashboard-filter-row .dropdown-toggle .filter-btn-value {
font-weight: 500;
}
.paper-page .dashboard-filter-row .dropdown-toggle .fas {
font-size: 11px;
color: var(--ink-muted);
}
.paper-page .dashboard-filter-row .dropdown-toggle:hover,
.paper-page .dashboard-filter-row .btn-group .btn:hover {
background: var(--hover-bg-color);
@@ -4222,20 +4289,42 @@ textarea::selection {
}
.paper-page .quota-value {
font-family: var(--font-mono);
font-size: 12px;
color: var(--ink-soft);
font-size: 13px;
font-variant-numeric: tabular-nums;
color: var(--color);
}
.paper-page .quota-progress {
.paper-page .quota-value .quota-unlimited-tag {
font-family: var(--font-sans);
font-size: 12px;
color: var(--ink-muted);
margin-left: 4px;
}
/* Quota bars: ink by default, colour only when a limit is nearly reached.
Unlimited quotas render an empty track (no fill), never a full green bar. */
.paper-page .quota-track {
height: 6px;
background: var(--paper-bg-alt);
border: 1px solid var(--border-color);
border-radius: 999px;
margin-bottom: 0;
overflow: hidden;
width: 100%;
min-width: 0;
}
.paper-page .quota-progress .progress-bar {
.paper-page .quota-track .quota-fill {
height: 100%;
min-width: 2px;
border-radius: 999px;
background: var(--color);
transition: width 0.3s ease;
}
.paper-page .quota-track.quota-warn .quota-fill { background: var(--status-progress); }
.paper-page .quota-track.quota-danger .quota-fill { background: var(--status-error); }
.paper-page .quota-track.quota-unlimited {
background: repeating-linear-gradient(
-45deg,
var(--paper-bg-alt) 0 4px,
var(--border-soft, var(--border-color)) 4px 6px
);
}
/* Dashboard repo/PR list refinements */
@@ -4381,12 +4470,22 @@ textarea::selection {
grid-template-columns: 28px minmax(280px, 2.4fr) 90px 120px 100px 52px !important;
gap: 12px !important;
}
/* Dashboard: conference folded into the name cell, numbers right-aligned. */
.paper-table.paper-table-dashboard .paper-table-head,
.paper-table.paper-table-dashboard .paper-table-row {
grid-template-columns: minmax(320px, 3fr) 190px 90px 170px 52px;
gap: 20px;
/* Two-line rows land at ~60px, close to Carbon's "large" density. */
padding: 8px 4px;
}
.paper-table.paper-table-dashboard .cell-anon .anon-text { gap: 2px; }
.paper-table.paper-table-dashboard .cell-anon .repo-name { padding: 0; }
.paper-table .paper-table-head {
background: transparent;
border-bottom: 1px solid var(--border-color);
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.14em;
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-muted);
padding-top: 8px;
@@ -4397,13 +4496,25 @@ textarea::selection {
user-select: none;
display: inline-flex;
align-items: center;
gap: 4px;
gap: 6px;
/* Sortable headers are real <button>s for keyboard users. */
background: none;
border: none;
padding: 2px 0;
margin: 0;
font: inherit;
letter-spacing: inherit;
text-transform: inherit;
color: inherit;
border-radius: 4px;
}
.paper-table .paper-table-head .sortable:hover { color: var(--color); }
.paper-table .paper-table-head .sortable.active { color: var(--color); }
.paper-table .paper-table-head .sortable i { font-size: 10px; }
/* Quantitative columns are right-aligned so digits line up. */
.paper-table .paper-table-head .num,
.paper-table .paper-table-row .num { text-align: left; }
.paper-table .paper-table-row .num { text-align: right; }
.paper-table .paper-table-head .num .sortable { flex-direction: row-reverse; }
.paper-table .paper-table-row {
border-top: 1px solid var(--border-soft, var(--border-color));
font-size: 14px;
@@ -4412,6 +4523,8 @@ textarea::selection {
.paper-table .paper-table-row:hover {
background: var(--hover-bg-color);
}
/* Rows that open a view on click get a pointer so hover means something. */
.paper-table .paper-table-row.row-clickable { cursor: pointer; }
/* Dim inactive rows through their cells rather than the row itself. Applying
opacity to the row creates a stacking context that traps the actions
dropdown menu (z-index) beneath the rows rendered after it, making its
@@ -4419,20 +4532,16 @@ textarea::selection {
interactive. */
.paper-table .paper-table-row.repo-inactive > *:not(.cell-actions) { opacity: 0.55; }
.paper-table .paper-table-row.repo-error {
background: rgba(180, 35, 24, 0.04);
border-left: 2px solid #B42318;
background: var(--status-error-soft);
border-left: 2px solid var(--status-error);
padding-left: 6px;
}
.paper-table .paper-table-row.repo-error:hover { background: rgba(180, 35, 24, 0.08); }
.dark-mode .paper-table .paper-table-row.repo-error { background: rgba(240, 138, 130, 0.05); border-left-color: #F08A82; }
.dark-mode .paper-table .paper-table-row.repo-error:hover { background: rgba(240, 138, 130, 0.1); }
.paper-table .paper-table-row.repo-error:hover { background: var(--status-error-soft-hover); }
.paper-table .cell-status .status-sub-error,
.cell-status .status-sub.status-sub-error {
color: #B42318;
font-family: var(--font-mono);
color: var(--status-error);
}
.dark-mode .paper-table .cell-status .status-sub-error,
.dark-mode .cell-status .status-sub.status-sub-error { color: #F08A82; }
.paper-table .cell-status .status-sub-warn { color: var(--status-progress); }
.paper-table .cell-anon {
display: flex;
align-items: center;
@@ -4444,8 +4553,16 @@ textarea::selection {
min-width: 52px;
justify-content: center;
}
.paper-table .cell-anon .anon-text { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.paper-table .cell-anon .anon-text { min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.paper-table .cell-anon .anon-title {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.paper-table .cell-anon .anon-title .type-badge { margin-left: 0; }
.paper-table .cell-anon .repo-name {
/* Identifiers stay mono; everything human-readable below is sans. */
font-family: var(--font-mono);
font-size: 14px;
color: var(--color);
@@ -4455,45 +4572,174 @@ textarea::selection {
white-space: nowrap;
display: block;
max-width: 100%;
padding: 2px 0;
}
.paper-table .cell-anon .repo-name-static { color: var(--ink-muted); font-style: italic; }
.paper-table .cell-anon .anon-sub {
font-size: 12px;
font-family: var(--font-sans);
font-size: 13px;
color: var(--ink-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.paper-table .cell-anon .anon-sub .anon-source {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* The conference tag keeps a readable share of the line; the source
truncates first since it is repeated in the name and in the tooltip. */
.paper-table .cell-anon .anon-sub .conf-tag {
flex: 0 1 auto;
min-width: 72px;
max-width: 50%;
}
.paper-table .cell-anon .anon-sub a {
color: var(--ink-muted);
border-bottom: 1px dotted var(--border-color);
/* Taller hit area without changing the line box */
padding: 4px 0;
margin: -4px 0;
}
.paper-table .cell-anon .anon-sub a { color: var(--ink-muted); border-bottom: 1px dotted var(--border-color); }
.paper-table .cell-anon .anon-sub a:hover { color: var(--color); }
.paper-table .cell-anon .anon-sub .commit-hash { font-family: var(--font-mono); font-size: 12px; }
/* Conference shown as a small tag inside the name cell. `.cell-conf` keeps
the clipping rules so a long conference string never bleeds. */
.paper-table .cell-conf {
font-family: var(--font-mono);
font-size: 13px;
color: var(--color);
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ink-muted);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.paper-table .cell-conf span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.paper-table .cell-status { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 8px; font-size: 14px; color: var(--color); }
.paper-table .cell-status { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 8px; font-size: 14px; color: var(--color); min-width: 0; }
.paper-table .cell-status .status-line { display: inline-flex; align-items: center; gap: 8px; }
.paper-table .cell-status .status-sub { flex-basis: 100%; font-size: 11px; line-height: 1.2; color: var(--ink-muted); }
.paper-table .cell-status .status-sub {
flex-basis: 100%;
font-family: var(--font-sans);
font-size: 13px;
line-height: 1.3;
color: var(--ink-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.paper-table .cell-views { font-family: var(--font-mono); font-variant-numeric: tabular-nums; color: var(--color); }
.paper-table .cell-expires { font-size: 13px; color: var(--ink-soft); }
.paper-table .empty-dash { color: var(--ink-muted); opacity: 0.5; }
.paper-table .cell-expires { font-family: var(--font-sans); font-size: 13px; color: var(--ink-soft); }
.paper-table .cell-expires .expires-never { color: var(--ink-muted); }
.paper-table .cell-expires .expires-past { color: var(--ink-muted); }
.paper-table .empty-dash { color: var(--ink-faint); }
.paper-table .cell-actions { display: flex; justify-content: flex-end; }
/* Status dots use the semantic tokens so they stay legible in both themes.
Old status-* class names are kept as aliases for other templates. */
.status-dot {
width: 8px;
height: 8px;
border-radius: 999px;
display: inline-block;
background: var(--ink-muted);
flex-shrink: 0;
background: var(--status-inactive);
}
.status-dot.status-ready { background: #2F7A44; }
.status-dot.status-error { background: #B42318; }
.status-dot.status-preparing { background: #C48A2E; }
.status-dot.status-removed { background: #9A8F7B; }
.status-dot.status-ready { background: var(--status-ready); }
.status-dot.status-error { background: var(--status-error); }
.status-dot.status-progress,
.status-dot.status-preparing { background: var(--status-progress); }
.status-dot.status-expired,
.status-dot.status-removed { background: var(--status-inactive); }
/* ---- Dashboard extras ---------------------------------------------- */
/* Room for the fixed Ko-fi pill so it never covers the last row's actions. */
.dashboard-page { padding-bottom: 96px; }
.dashboard-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
margin-top: 12px;
font-size: 13px;
color: var(--ink-muted);
}
.dashboard-meta .dashboard-count { font-variant-numeric: tabular-nums; }
.btn-link-inline {
background: none;
border: none;
padding: 2px 4px;
margin: -2px 0;
font: inherit;
color: var(--link-color);
cursor: pointer;
border-radius: 4px;
}
.btn-link-inline:hover { color: var(--link-hover-color); text-decoration: underline; }
.btn.btn-outline-ink {
background: transparent;
color: var(--color);
border: 1px solid var(--border-color);
font-weight: 500;
border-radius: var(--radius-control, 6px);
padding: 8px 14px;
}
.btn.btn-outline-ink:hover { background: var(--hover-bg-color); color: var(--color); border-color: var(--color); }
.paper-page .dropdown-menu .dropdown-item-danger,
.paper-page .dropdown-menu .dropdown-item-danger i { color: var(--status-error); }
.paper-page .dropdown-menu .dropdown-item-danger:hover,
.paper-page .dropdown-menu .dropdown-item-danger:focus { background: var(--status-error-soft-hover); color: var(--status-error); }
.paper-page .dropdown-menu button.dropdown-item { width: 100%; text-align: left; background: none; border: none; }
.paper-page .dropdown-menu .dropdown-divider { border-top-color: var(--border-soft, var(--border-color)); margin: 4px 0; }
/* Loading skeleton */
.paper-table .paper-table-skeleton { pointer-events: none; }
.skeleton {
display: block;
background: var(--paper-bg-alt);
border-radius: 4px;
position: relative;
overflow: hidden;
}
.skeleton::after {
content: "";
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(90deg, transparent, var(--hover-bg-color), transparent);
animation: skeleton-shimmer 1.4s infinite;
}
.skeleton-line { height: 12px; }
.skeleton-line + .skeleton-line { margin-top: 8px; }
.skeleton-line-sm { height: 10px; }
.skeleton-badge { width: 52px; height: 22px; border-radius: 999px; flex-shrink: 0; }
@keyframes skeleton-shimmer { to { transform: translateX(100%); } }
@media (prefers-reduced-motion: reduce) { .skeleton::after { animation: none; } }
/* Empty state with a way out */
.paper-table .paper-table-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 56px 20px;
color: var(--ink-muted);
text-align: center;
border-top: 1px solid var(--border-soft, var(--border-color));
}
.paper-table .paper-table-empty i { font-size: 1.6rem; color: var(--ink-faint); }
.paper-table .paper-table-empty-actions { display: flex; gap: 8px; margin-top: 8px; }
.bulk-bar {
display: flex;
@@ -5297,13 +5543,18 @@ textarea::selection {
.paper-table .cell-conf,
.paper-table .cell-expires {
order: 3;
font-size: 12px;
font-size: 13px;
color: var(--ink-muted);
display: inline-flex;
align-items: center;
gap: 6px;
text-align: left;
}
/* Status, date, views and expiry sit on one meta line with a common
baseline; the sub-text is inline instead of wrapping under the word. */
.paper-table .cell-status { flex-wrap: nowrap; gap: 6px; min-width: 0; }
.paper-table .cell-status .status-sub { flex-basis: auto; font-size: 13px; line-height: inherit; }
.paper-table .cell-status .status-sub::before { content: "·"; margin-right: 6px; color: var(--ink-faint); }
.paper-table .cell-conf {
max-width: 100%;
overflow: hidden;
@@ -5311,6 +5562,9 @@ textarea::selection {
white-space: nowrap;
}
.paper-table .cell-views.num { text-align: left; }
.paper-table .paper-table-skeleton .cell-status,
.paper-table .paper-table-skeleton .cell-views,
.paper-table .paper-table-skeleton .cell-expires { flex: 1 1 30%; }
.paper-table .cell-views::before {
content: '\f06e';
font-family: 'Font Awesome 5 Free';
@@ -5332,15 +5586,19 @@ textarea::selection {
font-size: 10px;
color: var(--ink-muted);
}
/* The dashboard renders its own icon inside the conference tag. */
.paper-table-dashboard .cell-conf::before { content: none; }
.paper-table .cell-conf:has(.empty-dash),
.paper-table .cell-expires:has(.empty-dash) { display: none; }
.paper-table .cell-views::before,
.paper-table .cell-expires::before { color: var(--ink-faint); }
}
/* Paper-themed dropdown menus (Sort / Status / Actions) */
.paper-page .dropdown-menu {
background: var(--paper-card);
border: 1px solid var(--border-color);
border-radius: 8px;
border-radius: var(--radius-surface, 10px);
box-shadow: 0 12px 28px rgba(26, 24, 21, 0.08);
padding: 6px;
min-width: 220px;
@@ -5448,25 +5706,33 @@ textarea::selection {
.paper-page .paper-page-lede { font-size: 13px; line-height: 1.4; margin-bottom: 0; }
.paper-page .btn-ink { padding: 6px 12px; font-size: 13px; }
/* Quotas stack in one column on phones so labels and values keep the
same 11px / 13px sizes as desktop instead of shrinking to 9px. */
.paper-page .quota-row {
grid-template-columns: repeat(3, 1fr);
gap: 10px 12px;
grid-template-columns: 1fr;
gap: 12px;
padding: 0;
margin: 12px 0 0;
margin: 16px 0 0;
}
.paper-page .dashboard-filter-row {
padding: 8px 0;
margin-top: 10px;
padding: 10px 0;
margin-top: 12px;
gap: 8px;
}
.paper-page .quota-header {
flex-direction: column;
align-items: flex-start;
gap: 2px;
flex-direction: row;
align-items: baseline;
gap: 8px;
}
.paper-page .quota-label { font-size: 10px; letter-spacing: 0.1em; }
.paper-page .quota-value { font-size: 11px; }
.paper-page .quota-value { white-space: nowrap; }
.paper-page .dashboard-filter-row { gap: 8px; }
/* Filter controls: type group fills the row, Sort and Status share one. */
.paper-page .dashboard-filter-controls { width: 100%; }
.paper-page .dashboard-filter-controls .btn-group { flex: 1 1 100%; display: flex; }
.paper-page .dashboard-filter-controls .btn-group .btn { flex: 1; justify-content: center; }
.paper-page .dashboard-filter-controls .dropdown { flex: 1 1 0; min-width: 0; }
.paper-page .dashboard-filter-controls .dropdown .dropdown-toggle { width: 100%; justify-content: space-between; }
.paper-page .dashboard-filter-controls .dropdown .filter-btn-value { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
}
@media (max-width: 480px) {
@@ -5476,9 +5742,6 @@ textarea::selection {
-webkit-box-orient: vertical;
overflow: hidden;
}
.paper-page .quota-row { gap: 8px; }
.paper-page .quota-label { font-size: 9px; letter-spacing: 0.06em; }
.paper-page .quota-value { font-size: 10px; white-space: nowrap; }
}
/* Empty state */
@@ -5510,10 +5773,12 @@ textarea::selection {
font-size: 10px;
line-height: 10px;
}
.paper-page .progress .progress-bar.bg-success { background: #2F7A44; }
.paper-page .progress .progress-bar.bg-warning { background: #C48A2E; color: #fff; }
.paper-page .progress .progress-bar.bg-danger { background: #B42318; }
.paper-page .progress .progress-bar.bg-dark { background: var(--ink-muted); }
/* Bootstrap's .bg-* utilities are !important, so the paper palette must be
too, otherwise the bright Bootstrap green/yellow/red leak through. */
.paper-page .progress .progress-bar.bg-success { background: var(--status-ready) !important; }
.paper-page .progress .progress-bar.bg-warning { background: var(--status-progress) !important; color: #fff; }
.paper-page .progress .progress-bar.bg-danger { background: var(--status-error) !important; }
.paper-page .progress .progress-bar.bg-dark { background: var(--ink-faint) !important; }
.paper-page > h5,
.paper-page form > h5,
+189 -176
View File
@@ -5,256 +5,269 @@
<div class="d-flex align-items-end justify-content-between flex-wrap" style="gap: 12px;">
<div>
<h1 class="paper-page-title">Your <em>anonymizations</em></h1>
<p class="paper-page-lede">Every repository and pull request you&rsquo;ve mirrored, with live status and stats.</p>
<p class="paper-page-lede">Every repository, pull request, and gist you&rsquo;ve mirrored, with live status and stats.</p>
</div>
<a href="/anonymize" class="btn btn-ink">
<i class="fa fa-plus-circle mr-1" aria-hidden="true"></i> New anonymization
</a>
</div>
<!-- Quota bars -->
<div class="quota-row" ng-show="quota">
<div class="quota-item">
<!-- Quota -->
<div class="quota-row" ng-if="quota">
<div class="quota-item" ng-repeat="q in [
{key: 'repository', label: 'Repositories', kind: 'count'},
{key: 'storage', label: 'Storage', kind: 'bytes'},
{key: 'file', label: 'Files', kind: 'count'}
]">
<div class="quota-header">
<span class="quota-label">Repositories</span>
<span class="quota-value" ng-show="quota">{{quota.repository.used | number}}/{{quota.repository.total}}</span>
<span class="quota-label">{{q.label}}</span>
<span class="quota-value" ng-if="q.kind === 'count'">
{{quota[q.key].used | number}}<span ng-if="!quota[q.key].unlimited"> / {{quota[q.key].total | number}}</span>
<span class="quota-unlimited-tag" ng-if="quota[q.key].unlimited">Unlimited</span>
</span>
<span class="quota-value" ng-if="q.kind === 'bytes'">
{{quota[q.key].used | humanFileSize}}<span ng-if="!quota[q.key].unlimited"> / {{quota[q.key].total | humanFileSize}}</span>
<span class="quota-unlimited-tag" ng-if="quota[q.key].unlimited">Unlimited</span>
</span>
</div>
<div class="progress quota-progress">
<div
class="progress-bar"
ng-class="{'bg-success': quota.repository.percent < 25 || quota.repository.total == 0, 'bg-danger': quota.repository.percent > 95 && quota.repository.total > 0, 'bg-warning': quota.repository.percent > 75 && quota.repository.total > 0 }"
role="progressbar"
style="width: {{quota.repository.percent}}%;"
aria-valuenow="{{quota.repository.used}}"
aria-valuemin="0"
aria-valuemax="{{quota.repository.total}}"
></div>
</div>
</div>
<div class="quota-item">
<div class="quota-header">
<span class="quota-label">Storage</span>
<span class="quota-value" ng-show="quota">{{quota.storage.used | humanFileSize}}/{{quota.storage.total | humanFileSize}}</span>
</div>
<div class="progress quota-progress">
<div
class="progress-bar"
ng-class="{'bg-success': quota.storage.percent < 25 || quota.storage.total == 0, 'bg-danger': quota.storage.percent > 95 && quota.storage.total > 0, 'bg-warning': quota.storage.percent > 75 && quota.storage.total > 0 }"
role="progressbar"
style="width: {{quota.storage.percent}}%;"
aria-valuenow="{{quota.storage.used}}"
aria-valuemin="0"
aria-valuemax="{{quota.storage.total}}"
></div>
</div>
</div>
<div class="quota-item">
<div class="quota-header">
<span class="quota-label">Files</span>
<span class="quota-value" ng-show="quota">{{quota.file.used | number}}/{{quota.file.total || "&#8734;"}}</span>
</div>
<div class="progress quota-progress">
<div
class="progress-bar"
ng-class="{'bg-success': quota.file.percent < 25 || quota.file.total == 0, 'bg-danger': quota.file.percent > 95 && quota.file.total > 0, 'bg-warning': quota.file.percent > 75 && quota.file.total > 0 }"
role="progressbar"
style="width: {{quota.file.percent}}%;"
aria-valuenow="{{quota.file.used}}"
aria-valuemin="0"
aria-valuemax="{{quota.file.total}}"
></div>
<div
class="quota-track"
ng-class="'quota-' + quota[q.key].level"
role="progressbar"
aria-label="{{q.label}} quota"
aria-valuenow="{{quota[q.key].used}}"
aria-valuemin="0"
aria-valuemax="{{quota[q.key].unlimited ? quota[q.key].used : quota[q.key].total}}"
aria-valuetext="{{quota[q.key].unlimited ? 'unlimited' : (quota[q.key].percent | number:0) + '% used'}}"
>
<div class="quota-fill" ng-if="!quota[q.key].unlimited" ng-style="{width: quota[q.key].percent + '%'}"></div>
</div>
</div>
</div>
<!-- Search + filters row -->
<form class="w-100 dashboard-filter-row" aria-label="Dashboard" accept-charset="UTF-8">
<!-- Search + filters -->
<form class="w-100 dashboard-filter-row" aria-label="Filter anonymizations" accept-charset="UTF-8" ng-submit="$event.preventDefault()">
<div class="search-wrap">
<input
type="search"
id="search"
class="form-control"
aria-label="Search..."
placeholder="Search anonymizations…"
aria-label="Search anonymizations"
placeholder="Search by name, source, or conference…"
autocomplete="off"
ng-model="search"
/>
</div>
<div class="d-flex flex-wrap" style="gap: 8px">
<!-- Type filter -->
<div class="btn-group btn-group-sm" role="group">
<button type="button" class="btn" ng-class="{'btn-primary': typeFilter === 'all'}" ng-click="typeFilter = 'all'">All</button>
<button type="button" class="btn" ng-class="{'btn-primary': typeFilter === 'repo'}" ng-click="typeFilter = 'repo'">Repos</button>
<button type="button" class="btn" ng-class="{'btn-primary': typeFilter === 'pr'}" ng-click="typeFilter = 'pr'">PRs</button>
<button type="button" class="btn" ng-class="{'btn-primary': typeFilter === 'gist'}" ng-click="typeFilter = 'gist'">Gists</button>
</div>
<div class="dashboard-filter-controls">
<div class="btn-group" role="group" aria-label="Type">
<button type="button" class="btn" ng-class="{'btn-primary': typeFilter === 'all'}" aria-pressed="{{typeFilter === 'all'}}" ng-click="typeFilter = 'all'">All</button>
<button type="button" class="btn" ng-class="{'btn-primary': typeFilter === 'repo'}" aria-pressed="{{typeFilter === 'repo'}}" ng-click="typeFilter = 'repo'">Repos</button>
<button type="button" class="btn" ng-class="{'btn-primary': typeFilter === 'pr'}" aria-pressed="{{typeFilter === 'pr'}}" ng-click="typeFilter = 'pr'">PRs</button>
<button type="button" class="btn" ng-class="{'btn-primary': typeFilter === 'gist'}" aria-pressed="{{typeFilter === 'gist'}}" ng-click="typeFilter = 'gist'">Gists</button>
</div>
<div class="dropdown">
<button
class="btn btn-sm dropdown-toggle"
type="button"
id="dropdownSort"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
>
Sort
</button>
<div class="dropdown-menu" aria-labelledby="dropdownSort">
<h6 class="dropdown-header">Select order</h6>
<div class="form-check dropdown-item">
<input class="form-check-input" type="radio" name="sort" id="sortFullName" value="_name" ng-model="orderBy" />
<label class="form-check-label" for="sortFullName">Name</label>
</div>
<div class="form-check dropdown-item">
<input class="form-check-input" type="radio" name="sort" id="sortAnonymizeDate" value="-anonymizeDate" ng-model="orderBy" />
<label class="form-check-label" for="sortAnonymizeDate">Anonymize Date</label>
</div>
<div class="form-check dropdown-item">
<input class="form-check-input" type="radio" name="sort" id="sortStatus" value="-status" ng-model="orderBy" />
<label class="form-check-label" for="sortStatus">Status</label>
</div>
<div class="form-check dropdown-item">
<input class="form-check-input" type="radio" name="sort" id="sortLastView" value="-lastView" ng-model="orderBy" />
<label class="form-check-label" for="sortLastView">Last View</label>
</div>
<div class="form-check dropdown-item">
<input class="form-check-input" type="radio" name="sort" id="sortPageView" value="-pageView" ng-model="orderBy" />
<label class="form-check-label" for="sortPageView">Page View</label>
</div>
<div class="dropdown">
<button
class="btn dropdown-toggle"
type="button"
id="dropdownSort"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
>
<span class="filter-btn-label">Sort</span>
<span class="filter-btn-value">{{sortLabel()}}</span>
<i class="fas" ng-class="sortDesc() ? 'fa-arrow-down' : 'fa-arrow-up'" aria-hidden="true"></i>
<span class="sr-only">{{sortDesc() ? 'descending' : 'ascending'}}</span>
</button>
<div class="dropdown-menu" aria-labelledby="dropdownSort">
<h6 class="dropdown-header">Order by</h6>
<div class="form-check dropdown-item" ng-repeat="(field, def) in sortFields">
<input class="form-check-input" type="radio" name="sort" id="sort-{{$index}}" ng-checked="isSortedBy(field)" ng-click="setSort(field, def.defaultDesc)" />
<label class="form-check-label" for="sort-{{$index}}">{{def.label}}</label>
</div>
</div>
<div class="dropdown">
<button
class="btn btn-sm dropdown-toggle"
type="button"
id="dropdownStatus"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
>
Status
<div class="dropdown-divider"></div>
<button type="button" class="dropdown-item" ng-click="toggleSortDirection()">
<i class="fas fa-exchange-alt fa-rotate-90" aria-hidden="true"></i>
{{sortDesc() ? 'Switch to ascending' : 'Switch to descending'}}
</button>
<div class="dropdown-menu" aria-labelledby="dropdownStatus">
<h6 class="dropdown-header">Select status</h6>
<div class="form-check dropdown-item">
<input class="form-check-input" type="checkbox" id="statusReady" value="ready" ng-model="filters.status.ready" />
<label class="form-check-label" for="statusReady">Ready</label>
</div>
<div class="form-check dropdown-item">
<input class="form-check-input" type="checkbox" id="statusExpired" value="expired" ng-model="filters.status.expired" />
<label class="form-check-label" for="statusExpired">Expired</label>
</div>
<div class="form-check dropdown-item">
<input class="form-check-input" type="checkbox" id="statusRemoved" value="removed" ng-model="filters.status.removed" />
<label class="form-check-label" for="statusRemoved">Removed</label>
</div>
</div>
</div>
<div class="dropdown">
<button
class="btn dropdown-toggle"
type="button"
id="dropdownStatus"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
>
<span class="filter-btn-label">Status</span>
<span class="filter-btn-value" ng-if="!hasHiddenStatus()">All</span>
<span class="filter-btn-value" ng-if="hasHiddenStatus()">{{hiddenStatusCount()}} hidden</span>
</button>
<div class="dropdown-menu" aria-labelledby="dropdownStatus">
<h6 class="dropdown-header">Show statuses</h6>
<div class="form-check dropdown-item" ng-repeat="(key, label) in statusKeyLabels">
<input class="form-check-input" type="checkbox" id="status-{{key}}" ng-model="filters.status[key]" />
<label class="form-check-label" for="status-{{key}}">{{label}}</label>
</div>
</div>
</div>
</div>
</form>
<!-- Active filter chips -->
<div class="d-flex flex-wrap mt-2" style="gap: 6px" ng-show="filters.status.ready === false || filters.status.expired === false || filters.status.removed === false">
<span class="filter-chip" ng-show="!v" ng-repeat="(f, v) in filters.status">
{{f | title}}
<button type="button" class="filter-chip-close" aria-label="Remove filter" ng-click="filters.status[f] = true;">
&times;
</button>
<!-- Result count + active filter chips -->
<div class="dashboard-meta" ng-if="!loading">
<span class="dashboard-count">
<span ng-if="filteredItems.length === items.length">{{items.length | number}} anonymization<span ng-if="items.length !== 1">s</span></span>
<span ng-if="filteredItems.length !== items.length">{{filteredItems.length | number}} of {{items.length | number}} shown</span>
</span>
<span class="filter-chip" ng-repeat="(f, v) in filters.status" ng-if="v === false">
Hiding {{statusKeyLabels[f]}}
<button type="button" class="filter-chip-close" aria-label="Show {{statusKeyLabels[f]}} again" ng-click="filters.status[f] = true;">&times;</button>
</span>
<button type="button" class="btn-link-inline" ng-if="hasActiveFilters()" ng-click="clearFilters()">Clear filters</button>
</div>
</div>
<!-- Table -->
<div class="paper-table w-100" role="table" aria-label="Anonymizations">
<div class="paper-table paper-table-dashboard w-100" role="table" aria-label="Anonymizations" aria-busy="{{loading}}">
<div class="paper-table-head" role="row">
<div role="columnheader">Anonymization</div>
<div role="columnheader">Conference</div>
<div role="columnheader">Status</div>
<div role="columnheader" class="num">Views</div>
<div role="columnheader">Expires</div>
<div role="columnheader" aria-label="Actions"></div>
<div role="columnheader" aria-sort="{{isSortedBy('_name') ? (sortDesc() ? 'descending' : 'ascending') : 'none'}}">
<button type="button" class="sortable" ng-class="{active: isSortedBy('_name')}" ng-click="setSort('_name')">
Anonymization <i class="fas" ng-if="isSortedBy('_name')" ng-class="sortDesc() ? 'fa-arrow-down' : 'fa-arrow-up'" aria-hidden="true"></i>
</button>
</div>
<div role="columnheader" aria-sort="{{isSortedBy('status') ? (sortDesc() ? 'descending' : 'ascending') : 'none'}}">
<button type="button" class="sortable" ng-class="{active: isSortedBy('status')}" ng-click="setSort('status')">
Status <i class="fas" ng-if="isSortedBy('status')" ng-class="sortDesc() ? 'fa-arrow-down' : 'fa-arrow-up'" aria-hidden="true"></i>
</button>
</div>
<div role="columnheader" class="num" aria-sort="{{isSortedBy('pageView') ? (sortDesc() ? 'descending' : 'ascending') : 'none'}}">
<button type="button" class="sortable" ng-class="{active: isSortedBy('pageView')}" ng-click="setSort('pageView')">
Views <i class="fas" ng-if="isSortedBy('pageView')" ng-class="sortDesc() ? 'fa-arrow-down' : 'fa-arrow-up'" aria-hidden="true"></i>
</button>
</div>
<div role="columnheader" aria-sort="{{isSortedBy('options.expirationDate') ? (sortDesc() ? 'descending' : 'ascending') : 'none'}}">
<button type="button" class="sortable" ng-class="{active: isSortedBy('options.expirationDate')}" ng-click="setSort('options.expirationDate')">
Expires <i class="fas" ng-if="isSortedBy('options.expirationDate')" ng-class="sortDesc() ? 'fa-arrow-down' : 'fa-arrow-up'" aria-hidden="true"></i>
</button>
</div>
<div role="columnheader"><span class="sr-only">Actions</span></div>
</div>
<!-- Loading skeleton: keeps the layout stable while the three lists load -->
<div class="paper-table-row paper-table-skeleton" role="row" aria-hidden="true" ng-if="loading" ng-repeat="i in [1, 2, 3, 4]">
<div class="cell-anon" role="cell"><span class="skeleton skeleton-badge"></span><div class="anon-text"><span class="skeleton skeleton-line" style="width: 38%"></span><span class="skeleton skeleton-line skeleton-line-sm" style="width: 56%"></span></div></div>
<div class="cell-status" role="cell"><span class="skeleton skeleton-line" style="width: 60%"></span></div>
<div class="cell-views num" role="cell"><span class="skeleton skeleton-line" style="width: 40%"></span></div>
<div class="cell-expires" role="cell"><span class="skeleton skeleton-line" style="width: 55%"></span></div>
<div class="cell-actions" role="cell"></div>
</div>
<div
class="paper-table-row"
role="row"
ng-class="{'repo-inactive': item.status == 'expired' || item.status == 'removed', 'repo-error': item.status == 'error'}"
ng-repeat="item in items | filter:itemFilter | orderBy:orderBy as filteredItems"
ng-if="!loading"
ng-class="{'repo-inactive': item._statusKey == 'expired' || item._statusKey == 'removed', 'repo-error': item.status == 'error', 'row-clickable': !!item._viewUrl}"
ng-repeat="item in items | filter:itemFilter | orderBy:orderBy as filteredItems track by (item._type + ':' + (item._id || $index))"
ng-click="openItem(item, $event)"
>
<div class="cell-anon" role="cell">
<span class="type-badge" ng-class="{'type-repo': item._type === 'repo', 'type-pr': item._type === 'pr', 'type-gist': item._type === 'gist'}">{{item._type === 'repo' ? 'Repo' : item._type === 'pr' ? 'PR' : 'Gist'}}</span>
<span class="type-badge type-coauthor" ng-if="item.role === 'coauthor'" title="You are a co-author on this anonymization">Co-author</span>
<div class="anon-text">
<a ng-href="{{item._viewUrl}}" class="repo-name" ng-bind="item._name"></a>
<div class="anon-sub">
<a ng-if="item._type === 'repo'" href="https://github.com/{{item.source.fullName}}/" ng-bind="item.source.fullName"></a><span ng-if="item._type === 'repo' && item.options.update">&nbsp;&middot;&nbsp;<a href="https://github.com/{{item.source.fullName}}/tree/{{item.source.branch}}" ng-bind="item.source.branch"></a><span ng-if="item.source.commit">&nbsp;&middot;&nbsp;@<a href="https://github.com/{{item.source.fullName}}/tree/{{item.source.commit}}" ng-bind="item.source.commit.substring(0, 8)"></a></span></span><span ng-if="item._type === 'repo' && !item.options.update">&nbsp;&middot;&nbsp;@<a href="https://github.com/{{item.source.fullName}}/tree/{{item.source.commit}}" ng-bind="item.source.commit.substring(0, 8)"></a></span>
<a ng-if="item._type === 'pr'" href="https://github.com/{{item.source.repositoryFullName}}/pull/{{item.source.pullRequestId}}" ng-bind="item._source"></a>
<a ng-if="item._type === 'gist'" href="https://gist.github.com/{{item.source.gistId}}" ng-bind="item._source"></a>
<div class="anon-title">
<a ng-if="item._viewUrl" ng-href="{{item._viewUrl}}" class="repo-name" ng-bind="item._name"></a>
<span ng-if="!item._viewUrl" class="repo-name repo-name-static" ng-bind="item._name"></span>
<span class="type-badge type-coauthor" ng-if="item.role === 'coauthor'" title="You are a co-author on this anonymization">Co-author</span>
</div>
<div class="anon-sub" ng-if="!item._broken || item.conference">
<span class="anon-source" ng-if="item._type === 'repo' && item.source.fullName && !item._broken">
<a href="https://github.com/{{item.source.fullName}}/" target="_blank" rel="noopener" ng-bind="item.source.fullName"></a><span ng-if="item.options.update && item.source.branch"> &middot; <a href="https://github.com/{{item.source.fullName}}/tree/{{item.source.branch}}" target="_blank" rel="noopener" ng-bind="item.source.branch"></a></span><span ng-if="item.source.commit"> &middot; <a class="commit-hash" href="https://github.com/{{item.source.fullName}}/tree/{{item.source.commit}}" target="_blank" rel="noopener">@{{item.source.commit.substring(0, 8)}}</a></span>
</span>
<span class="anon-source" ng-if="item._type === 'pr' && item.source.repositoryFullName && !item._broken">
<a href="https://github.com/{{item.source.repositoryFullName}}/pull/{{item.source.pullRequestId}}" target="_blank" rel="noopener" ng-bind="item._source"></a>
</span>
<span class="anon-source" ng-if="item._type === 'gist' && item.source.gistId && !item._broken">
<a href="https://gist.github.com/{{item.source.gistId}}" target="_blank" rel="noopener" ng-bind="item._source"></a>
</span>
<span class="cell-conf conf-tag" ng-if="item.conference" title="{{item.conference}}"><i class="fas fa-university" aria-hidden="true"></i> <span ng-bind="item.conference"></span></span>
</div>
</div>
</div>
<div class="cell-conf" role="cell">
<span ng-if="item.conference" title="{{item.conference}}" ng-bind="item.conference"></span>
<span class="empty-dash" ng-if="!item.conference">&mdash;</span>
</div>
<div class="cell-status" role="cell">
<div class="status-line">
<span
class="status-dot"
ng-class="{'status-removed': item.status == 'removed' || item.status == 'expired' || item.status == 'removing' || item.status == 'expiring', 'status-preparing': item.status == 'preparing' || item.status == 'download', 'status-ready': item.status == 'ready', 'status-error': item.status == 'error'}"
></span>
<span ng-bind="item.status | title"></span>
<span class="status-dot" ng-class="'status-' + item._statusKey" aria-hidden="true"></span>
<span class="status-word" ng-bind="item.status | statusLabel"></span>
</div>
<div class="status-sub status-sub-error" ng-if="item.status == 'error' && item.statusMessage" title="{{item.statusMessage}}" ng-bind="item.statusMessage | statusMsg"></div>
<div class="status-sub" ng-if="item.status != 'error' && item.anonymizeDate" title="Last anonymized {{item.anonymizeDate | humanTime}}" ng-bind="item.anonymizeDate | humanTime"></div>
<div class="status-sub status-sub-warn" ng-if="item._stale" title="Last activity {{item.anonymizeDate || item.lastView | humanTime}}">
Last activity {{item.anonymizeDate || item.lastView | humanTime}} &middot; may be stuck
</div>
<div class="status-sub" ng-if="item.status != 'error' && !item._stale && item.anonymizeDate">
anonymized {{item.anonymizeDate | humanTime}}
</div>
</div>
<div class="cell-views num" role="cell" ng-bind="item.pageView | number"></div>
<div class="cell-expires" role="cell">
<span ng-if="item.options.expirationMode !== 'never' && item.status == 'ready'" ng-bind="item.options.expirationDate | humanTime"></span>
<span class="empty-dash" ng-if="!(item.options.expirationMode !== 'never' && item.status == 'ready')">&mdash;</span>
<span ng-if="item._expiry.kind === 'never'" class="expires-never">Never</span>
<span ng-if="item._expiry.kind === 'date'" ng-bind="item._expiry.date | humanTime"></span>
<span ng-if="item._expiry.kind === 'expired' && item._expiry.date" class="expires-past">Expired {{item._expiry.date | humanTime}}</span>
<span ng-if="item._expiry.kind === 'expired' && !item._expiry.date" class="expires-past">Expired</span>
<span class="empty-dash" ng-if="item._expiry.kind === 'none'" aria-label="Not applicable">&mdash;</span>
</div>
<div class="cell-actions" role="cell">
<div class="dropdown">
<div class="dropdown" ng-if="!item._broken">
<button
class="btn btn-icon-dots"
type="button"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
aria-label="Actions"
aria-label="Actions for {{item._name}}"
>
<i class="fas fa-ellipsis-h" aria-hidden="true"></i>
</button>
<div class="dropdown-menu dropdown-menu-right">
<a class="dropdown-item" ng-href="{{item._editUrl}}">
<i class="far fa-edit" aria-hidden="true"></i> Edit
</a>
<a class="dropdown-item" href="#" ng-show="item.status == 'ready' || item.status == 'error'" ng-click="refreshItem(item)">
<i class="fas fa-sync"></i> Force update
</a>
<a class="dropdown-item" href="#" ng-show="item.status == 'removed'" ng-click="refreshItem(item)">
<i class="fas fa-check-circle"></i> Enable
</a>
<a class="dropdown-item" href="#" ng-show="item.status == 'expired'" ng-click="extendItem(item)">
<i class="fas fa-calendar-plus"></i> Extend 6 months
</a>
<a class="dropdown-item" href="#" ng-show="(item.status == 'ready' || item.status == 'expired' || item.status == 'error') && item.role !== 'coauthor'" ng-click="removeItem(item)">
<i class="fas fa-trash-alt"></i> Remove
</a>
<a class="dropdown-item" ng-href="{{item._viewUrl}}">
<i class="fa fa-eye" aria-hidden="true"></i> View
</a>
<a class="dropdown-item" href="/w/{{item.repoId}}/" target="_self" ng-if="item._type === 'repo' && item.options.page && item.status == 'ready'">
<i class="fas fa-globe"></i> View Page
<i class="fas fa-globe" aria-hidden="true"></i> View page
</a>
<a class="dropdown-item" ng-href="{{item._editUrl}}">
<i class="far fa-edit" aria-hidden="true"></i> Edit
</a>
<button type="button" class="dropdown-item" ng-if="item.status == 'ready' || item.status == 'error'" ng-click="refreshItem(item)">
<i class="fas fa-sync" aria-hidden="true"></i> Force update
</button>
<button type="button" class="dropdown-item" ng-if="item.status == 'removed'" ng-click="refreshItem(item)">
<i class="fas fa-check-circle" aria-hidden="true"></i> Enable
</button>
<button type="button" class="dropdown-item" ng-if="item.status == 'expired'" ng-click="extendItem(item)">
<i class="fas fa-calendar-plus" aria-hidden="true"></i> Extend 6 months
</button>
<div ng-if="(item.status == 'ready' || item.status == 'expired' || item.status == 'error') && item.role !== 'coauthor'">
<div class="dropdown-divider"></div>
<button type="button" class="dropdown-item dropdown-item-danger" ng-click="removeItem(item)">
<i class="fas fa-trash-alt" aria-hidden="true"></i> Remove
</button>
</div>
</div>
</div>
</div>
</div>
<div class="paper-table-empty" ng-if="filteredItems.length == 0">
<i class="fas fa-inbox"></i>
<span>Nothing to display.</span>
<div class="paper-table-empty" ng-if="!loading && filteredItems.length == 0">
<i class="fas fa-inbox" aria-hidden="true"></i>
<span ng-if="items.length == 0">You have no anonymizations yet.</span>
<span ng-if="items.length > 0">Nothing matches the current filters.</span>
<div class="paper-table-empty-actions">
<button type="button" class="btn btn-outline-ink" ng-if="hasActiveFilters()" ng-click="clearFilters()">Clear filters</button>
<a href="/anonymize" class="btn btn-ink" ng-if="items.length == 0"><i class="fa fa-plus-circle mr-1" aria-hidden="true"></i> New anonymization</a>
</div>
</div>
</div>
</div>
+260 -77
View File
@@ -212,11 +212,19 @@ angular
seconds = Math.round((Date.now() - new Date(seconds)) / 1000);
var suffix = seconds < 0 ? "from now" : "ago";
// more than 2 days ago display Date
// more than 2 days ago display Date. Spell the month out so the date
// is unambiguous regardless of the reader's locale (9/6 vs 6/9).
if (Math.abs(seconds) > 2 * 60 * 60 * 24) {
const now = new Date();
now.setSeconds(now.getSeconds() - seconds);
return "on " + now.toLocaleDateString();
return (
"on " +
now.toLocaleDateString(undefined, {
day: "numeric",
month: "short",
year: "numeric",
})
);
}
seconds = Math.abs(seconds);
@@ -255,7 +263,44 @@ angular
return capitalized.join(" ");
};
})
// Human-readable labels for the raw status values stored on repositories,
// pull requests and gists. Anything unknown falls back to Title Case.
.filter("statusLabel", function () {
var labels = {
ready: "Ready",
error: "Error",
expired: "Expired",
expiring: "Expiring",
removed: "Removed",
removing: "Removing",
queue: "Queued",
download: "Downloading",
downloaded: "Downloaded",
preparing: "Preparing",
anonymizing: "Anonymizing",
};
return function (status) {
if (!status) return "";
if (labels[status]) return labels[status];
var s = String(status).replace(/[_-]+/g, " ").toLowerCase();
return s.charAt(0).toUpperCase() + s.slice(1);
};
})
.filter("statusMsg", function () {
// Known machine codes → sentences. Unknown snake_case codes are
// converted to a sentence instead of leaking `branch_not_found`.
var codes = {
branch_not_found: "Branch not found on GitHub",
repo_not_found: "Repository not found on GitHub",
repository_not_found: "Repository not found on GitHub",
repo_not_accessible: "Repository is not accessible with your token",
pr_not_found: "Pull request not found on GitHub",
gist_not_found: "Gist not found on GitHub",
commit_not_found: "Commit not found on GitHub",
repo_too_big: "Repository exceeds the size limit",
quota_exceeded: "Storage quota exceeded",
incomplete_record: "Incomplete record: missing identifier",
};
return function (msg) {
if (!msg) return msg;
var m = msg.match(/^rate_limited:(\d+)$/);
@@ -266,6 +311,11 @@ angular
var sec = remaining % 60;
return "Rate limited — retrying in " + (min > 0 ? min + "m " + sec + "s" : sec + "s");
}
if (codes[msg]) return codes[msg];
if (/^[a-z0-9]+(_[a-z0-9]+)+$/.test(msg)) {
var s = msg.replace(/_/g, " ");
return s.charAt(0).toUpperCase() + s.slice(1);
}
return msg;
};
})
@@ -1327,7 +1377,9 @@ angular
"$scope",
"$http",
"$location",
function ($scope, $http, $location) {
"$q",
"$window",
function ($scope, $http, $location, $q, $window) {
$scope.$on("$routeChangeStart", function () {
$('[data-toggle="tooltip"]').tooltip("dispose");
});
@@ -1346,11 +1398,35 @@ angular
$scope.items = [];
$scope.search = "";
$scope.loading = true;
// Status buckets used by the Status filter. Raw statuses are mapped
// onto these keys so in-progress and error items can be filtered too.
$scope.statusKeyLabels = {
ready: "Ready",
progress: "In progress",
error: "Error",
expired: "Expired",
removed: "Removed",
};
const inProgress = ["queue", "download", "downloaded", "preparing", "anonymizing"];
function statusKey(status) {
if (status === "ready" || status === "error") return status;
if (status === "expired" || status === "expiring") return "expired";
if (status === "removed" || status === "removing") return "removed";
if (inProgress.indexOf(status) > -1) return "progress";
return "progress";
}
// An in-progress item whose last activity is older than this is
// probably stuck; the row says so instead of showing "Downloading".
const STALE_AFTER_MS = 2 * 60 * 60 * 1000;
const dashboardPrefsKey = "dashboard.filterPrefs";
const dashboardPrefDefaults = {
typeFilter: "all",
filters: { status: { ready: true, expired: true, removed: false } },
filters: {
status: { ready: true, progress: true, error: true, expired: true, removed: false },
},
orderBy: "-anonymizeDate",
};
const savedDashboardPrefs = loadFilterPrefs(dashboardPrefsKey) || {};
@@ -1364,6 +1440,37 @@ angular
};
$scope.orderBy = savedDashboardPrefs.orderBy || dashboardPrefDefaults.orderBy;
// ---- Sorting -------------------------------------------------------
// `orderBy` is kept as the Angular orderBy expression ("-field" for
// descending) so saved preferences stay compatible.
const sortFields = {
_name: { label: "Name", defaultDesc: false },
anonymizeDate: { label: "Anonymize date", defaultDesc: true },
status: { label: "Status", defaultDesc: false },
lastView: { label: "Last view", defaultDesc: true },
pageView: { label: "Views", defaultDesc: true },
"options.expirationDate": { label: "Expiration", defaultDesc: false },
};
$scope.sortFields = sortFields;
$scope.sortField = () => $scope.orderBy.replace(/^-/, "");
$scope.sortDesc = () => $scope.orderBy.charAt(0) === "-";
$scope.sortLabel = () => {
const f = sortFields[$scope.sortField()];
return f ? f.label : "Custom";
};
$scope.isSortedBy = (field) => $scope.sortField() === field;
$scope.setSort = (field, desc) => {
if (typeof desc !== "boolean") {
desc = $scope.isSortedBy(field)
? !$scope.sortDesc()
: !!(sortFields[field] && sortFields[field].defaultDesc);
}
$scope.orderBy = (desc ? "-" : "") + field;
};
$scope.toggleSortDirection = () => {
$scope.setSort($scope.sortField(), !$scope.sortDesc());
};
$scope.$watchGroup(
["typeFilter", "orderBy"],
() => {
@@ -1386,94 +1493,168 @@ angular
true
);
// ---- Quota ---------------------------------------------------------
// A quota with total 0 is unlimited: no percentage, no fill. Colour is
// only introduced once a quota is nearly used up.
function decorateQuota(q) {
q.unlimited = !q.total;
q.percent = q.unlimited ? 0 : Math.min(100, (q.used * 100) / q.total);
q.level = q.unlimited
? "unlimited"
: q.percent >= 95
? "danger"
: q.percent >= 80
? "warn"
: "ok";
return q;
}
function getQuota() {
$http.get("/api/user/quota").then((res) => {
$scope.quota = res.data;
$scope.quota.storage.percent = $scope.quota.storage.total
? ($scope.quota.storage.used * 100) / $scope.quota.storage.total
: 100;
$scope.quota.file.percent = $scope.quota.file.total
? ($scope.quota.file.used * 100) / $scope.quota.file.total
: 100;
$scope.quota.repository.percent = $scope.quota.repository.total
? ($scope.quota.repository.used * 100) /
$scope.quota.repository.total
: 100;
decorateQuota($scope.quota.storage);
decorateQuota($scope.quota.file);
decorateQuota($scope.quota.repository);
}, console.error);
}
getQuota();
let loadedRepos = null;
let loadedPRs = null;
let loadedGists = null;
function mergeItems() {
$scope.items = (loadedRepos || [])
.concat(loadedPRs || [])
.concat(loadedGists || []);
// ---- Items ---------------------------------------------------------
// Fields shared by repositories, pull requests and gists. Records that
// lost their identifier (legacy data) are flagged as broken instead of
// rendering an empty link to /pr/undefined/.
function decorateItem(item, id, name, source, editUrl, viewUrl) {
if (!item.pageView) item.pageView = 0;
if (!item.lastView) item.lastView = "";
item.options = item.options || {};
item.options.terms = (item.options.terms || []).filter((f) => f);
item._id = id || "";
item._source = source;
item._broken = !id;
item._name = id || source || "(unnamed)";
item._editUrl = id ? editUrl : null;
item._viewUrl = id ? viewUrl : null;
if (item._broken) {
item.status = "error";
item.statusMessage = "incomplete_record";
}
item._statusKey = statusKey(item.status);
const last = item.anonymizeDate || item.lastView;
item._stale =
item._statusKey === "progress" &&
!!last &&
Date.now() - new Date(last).getTime() > STALE_AFTER_MS;
// What the Expires column should show.
if (item.status === "expired" || item.status === "expiring") {
item._expiry = { kind: "expired", date: item.options.expirationDate };
} else if (item.status !== "ready") {
item._expiry = { kind: "none" };
} else if (item.options.expirationMode === "never" || !item.options.expirationDate) {
item._expiry = { kind: "never" };
} else {
item._expiry = { kind: "date", date: item.options.expirationDate };
}
return item;
}
function safeGet(url) {
return $http.get(url).then(
(res) => res.data || [],
(err) => {
console.error(err);
return [];
}
);
}
// All three lists load in parallel and are merged once, so the table
// does not re-sort three times while it fills in.
function loadAll() {
loadedRepos = null;
loadedPRs = null;
loadedGists = null;
$http.get("/api/user/anonymized_repositories").then(
(res) => {
loadedRepos = res.data.map((repo) => {
if (!repo.pageView) repo.pageView = 0;
if (!repo.lastView) repo.lastView = "";
repo.options.terms = repo.options.terms.filter((f) => f);
$scope.loading = true;
return $q
.all([
safeGet("/api/user/anonymized_repositories"),
safeGet("/api/user/anonymized_pull_requests"),
safeGet("/api/user/anonymized_gists"),
])
.then((results) => {
const repos = results[0];
const prs = results[1];
const gists = results[2];
const items = [];
repos.forEach((repo) => {
repo._type = "repo";
repo._id = repo.repoId;
repo._name = repo.repoId;
repo._source = repo.source.fullName;
repo._editUrl = "/anonymize/" + repo.repoId;
repo._viewUrl = "/r/" + repo.repoId + "/";
return repo;
const src = repo.source || {};
items.push(
decorateItem(
repo,
repo.repoId,
repo.repoId,
src.fullName,
"/anonymize/" + repo.repoId,
"/r/" + repo.repoId + "/"
)
);
});
mergeItems();
},
(err) => { console.error(err); }
);
$http.get("/api/user/anonymized_pull_requests").then(
(res2) => {
loadedPRs = res2.data.map((pr) => {
if (!pr.pageView) pr.pageView = 0;
if (!pr.lastView) pr.lastView = "";
pr.options.terms = pr.options.terms.filter((f) => f);
prs.forEach((pr) => {
pr._type = "pr";
pr._id = pr.pullRequestId;
pr._name = pr.pullRequestId;
pr._source = pr.source.repositoryFullName + "#" + pr.source.pullRequestId;
pr._editUrl = "/pull-request-anonymize/" + pr.pullRequestId;
pr._viewUrl = "/pr/" + pr.pullRequestId + "/";
return pr;
const src = pr.source || {};
items.push(
decorateItem(
pr,
pr.pullRequestId,
pr.pullRequestId,
src.repositoryFullName + "#" + src.pullRequestId,
"/pull-request-anonymize/" + pr.pullRequestId,
"/pr/" + pr.pullRequestId + "/"
)
);
});
mergeItems();
},
(err) => { console.error(err); }
);
$http.get("/api/user/anonymized_gists").then(
(res3) => {
loadedGists = res3.data.map((g) => {
if (!g.pageView) g.pageView = 0;
if (!g.lastView) g.lastView = "";
g.options.terms = (g.options.terms || []).filter((f) => f);
gists.forEach((g) => {
g._type = "gist";
g._id = g.gistId;
g._name = g.gistId;
g._source = g.source.gistId;
g._editUrl = "/gist-anonymize/" + g.gistId;
g._viewUrl = "/gist/" + g.gistId + "/";
return g;
const src = g.source || {};
items.push(
decorateItem(
g,
g.gistId,
g.gistId,
src.gistId,
"/gist-anonymize/" + g.gistId,
"/gist/" + g.gistId + "/"
)
);
});
mergeItems();
},
(err) => { console.error(err); }
);
$scope.items = items;
$scope.loading = false;
});
}
loadAll();
// Whole row opens the anonymized view; clicks on links, buttons and the
// actions menu keep their own behaviour.
$scope.openItem = (item, $event) => {
if (!item._viewUrl) return;
const target = $event && $event.target;
if (target && target.closest && target.closest("a, button, .dropdown, input")) return;
$window.location.href = item._viewUrl;
};
$scope.hiddenStatusCount = () =>
Object.keys($scope.filters.status).filter((k) => $scope.filters.status[k] === false).length;
$scope.hasHiddenStatus = () => $scope.hiddenStatusCount() > 0;
$scope.hasActiveFilters = () =>
$scope.typeFilter !== "all" ||
$scope.search.trim().length > 0 ||
Object.keys($scope.filters.status).some((k) => $scope.filters.status[k] === false);
$scope.clearFilters = () => {
$scope.typeFilter = "all";
$scope.search = "";
Object.keys($scope.filters.status).forEach((k) => {
$scope.filters.status[k] = true;
});
};
function waitRepoToBeReady(repoId, callback) {
$http.get("/api/repo/" + repoId).then((res) => {
for (const item of $scope.items) {
@@ -1597,10 +1778,12 @@ angular
$scope.itemFilter = (item) => {
if ($scope.typeFilter !== "all" && item._type !== $scope.typeFilter) return false;
if ($scope.filters.status[item.status] == false) return false;
if ($scope.search.trim().length == 0) return true;
if (item._source && item._source.indexOf($scope.search) > -1) return true;
if (item._id.indexOf($scope.search) > -1) return true;
if ($scope.filters.status[item._statusKey] === false) return false;
const needle = $scope.search.trim().toLowerCase();
if (needle.length == 0) return true;
if (item._source && String(item._source).toLowerCase().indexOf(needle) > -1) return true;
if (item._id && String(item._id).toLowerCase().indexOf(needle) > -1) return true;
if (item.conference && String(item.conference).toLowerCase().indexOf(needle) > -1) return true;
return false;
};
},
+1 -1
View File
File diff suppressed because one or more lines are too long
+202
View File
@@ -0,0 +1,202 @@
/**
* Mock server for local UI work on authenticated pages (dashboard, etc.).
*
* Serves the local `public/` folder exactly like scripts/dev-proxy.js, but
* instead of proxying the API to the live site it answers the user-facing
* endpoints with fixture data. No database, no GitHub login required.
*
* node scripts/dev-mock.js # default port 4002
* PORT=5000 node scripts/dev-mock.js
*
* The fixtures deliberately include awkward records (expired, error,
* stuck download, legacy record without an id, conference tag, unlimited
* quota) so the dashboard's edge cases can be checked visually.
*/
const path = require("path");
const fs = require("fs");
const express = require("express");
const PORT = parseInt(process.env.PORT || "4002", 10);
const PUBLIC_DIR = path.resolve(__dirname, "..", "public");
const manifestPath = path.join(PUBLIC_DIR, "asset-manifest.json");
function asset(name) {
try {
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
return manifest[name] || name;
} catch {
return name;
}
}
function indexHtml() {
return fs
.readFileSync(path.join(PUBLIC_DIR, "index.html"), "utf-8")
.replace("__CORE_JS__", asset("core.min.js"))
.replace("__VENDOR_JS__", asset("vendor.min.js"))
.replace("__MERMAID_JS__", asset("mermaid.min.js"))
.replace("__ALL_CSS__", asset("all.min.css"));
}
// ---- Fixtures --------------------------------------------------------------
const DAY = 24 * 60 * 60 * 1000;
const ago = (days) => new Date(Date.now() - days * DAY).toISOString();
const ahead = (days) => new Date(Date.now() + days * DAY).toISOString();
const opts = (extra) =>
Object.assign(
{ terms: [], expirationMode: "never", update: false, page: false },
extra || {}
);
const repositories = [
{
repoId: "anonymous_github-C72C",
status: "ready",
anonymizeDate: ago(3),
lastView: ago(1),
pageView: 15,
conference: "FSE25",
source: { fullName: "tdurieux/anonymous_github", branch: "main", commit: "c40da900ab12" },
options: opts({ expirationMode: "redirect", expirationDate: ahead(420), update: true, page: true }),
},
{
repoId: "840c8c57-3c32-451e-bf12-0e20be300389",
status: "ready",
anonymizeDate: ago(120),
lastView: ago(0.2),
pageView: 596460,
source: { fullName: "tdurieux/anonymous_github", branch: "main", commit: "f817a29a0000" },
options: opts({ update: true }),
},
{
repoId: "JarSift",
status: "expired",
anonymizeDate: ago(560),
lastView: ago(30),
pageView: 72,
conference: "FSE25",
source: { fullName: "Cornul11/JarSift", commit: "035adc67ffff" },
options: opts({ expirationMode: "remove", expirationDate: ago(9) }),
},
{
repoId: "b4751b8e-6139-4a94-84f8-646106435809",
status: "error",
statusMessage: "branch_not_found",
anonymizeDate: ago(1990),
pageView: 0,
source: { fullName: "tdurieux/anonymous_github", branch: "master", commit: "a5b0c7c3aaaa" },
options: opts({ update: true }),
},
{
repoId: "runtime-repair-experiments-very-long-identifier-2ff4",
status: "ready",
anonymizeDate: ago(40),
pageView: 5,
conference: "International Conference on Software Engineering 2026 Artifact Track",
source: { fullName: "Spirals-Team/runtime-repair-experiments", branch: "master", commit: "2ff4cdef1234" },
options: opts({ expirationMode: "remove", expirationDate: ahead(1) , update: true }),
},
{
repoId: "coauthored-repo-91AB",
status: "ready",
role: "coauthor",
anonymizeDate: ago(12),
pageView: 41,
source: { fullName: "someone-else/paper-artifact", commit: "b5ab2e21dddd" },
options: opts(),
},
];
const pullRequests = [
{
pullRequestId: "5774",
status: "ready",
anonymizeDate: ago(2),
lastView: ago(2),
pageView: 9,
source: { repositoryFullName: "vyperlang/vyper", pullRequestId: 3224 },
options: opts(),
},
{
pullRequestId: "436B",
status: "download",
anonymizeDate: ago(126),
pageView: 12,
source: { repositoryFullName: "lncapital/torq", pullRequestId: 307 },
options: opts(),
},
{
pullRequestId: "7CF7",
status: "preparing",
anonymizeDate: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
pageView: 0,
source: { repositoryFullName: "Tiledesk/tiledesk-server", pullRequestId: 89 },
options: opts(),
},
{
// Legacy record without an identifier or options.
status: "ready",
pageView: 0,
source: { repositoryFullName: "atmoz/sftp", pullRequestId: 357 },
},
{
pullRequestId: "demo-pr",
status: "removed",
anonymizeDate: ago(1030),
pageView: 319,
source: { repositoryFullName: "tdurieux/anonymous_github", pullRequestId: 156 },
options: opts(),
},
];
const gists = [
{
gistId: "g-4f2a",
status: "ready",
anonymizeDate: ago(6),
pageView: 3,
source: { gistId: "a1b2c3d4e5f6a7b8c9d0" },
options: opts({ expirationMode: "remove", expirationDate: ahead(90) }),
},
];
const quota = {
storage: { used: 5.6 * 1024 * 1024, total: 2 * 1024 * 1024 * 1024 },
file: { used: 1381, total: 0 },
repository: { used: 17, total: 20 },
};
// ---- Server ----------------------------------------------------------------
const app = express();
app.get(/^\/(script|css)\/(.+)\.([a-f0-9]{10})\.(min\.\w+|\w+)$/, (req, res, next) => {
const filePath = path.join(PUBLIC_DIR, req.params[0], `${req.params[1]}.${req.params[3]}`);
if (!fs.existsSync(filePath)) return next();
res.setHeader("Cache-Control", "no-store");
res.sendFile(filePath);
});
app.get("/api/user", (req, res) => res.json({ username: "tdurieux", photo: "", isAdmin: false }));
app.get("/api/options", (req, res) => res.json({}));
app.get("/api/message", (req, res) => res.status(404).end());
app.get("/api/user/quota", (req, res) => res.json(quota));
app.get("/api/user/anonymized_repositories", (req, res) => res.json(repositories));
app.get("/api/user/anonymized_pull_requests", (req, res) => res.json(pullRequests));
app.get("/api/user/anonymized_gists", (req, res) => res.json(gists));
app.get("/api/user/default", (req, res) => res.json({}));
app.all("/api/{*rest}", (req, res) => res.status(404).json({ error: "not mocked: " + req.path }));
app.use((req, res, next) => {
res.setHeader("Cache-Control", "no-store, max-age=0");
next();
});
app.use(express.static(PUBLIC_DIR, { etag: false, cacheControl: false }));
app.get("/{*rest}", (req, res) => res.type("html").send(indexHtml()));
app.listen(PORT, () => {
console.log(`mock ui http://localhost:${PORT}/dashboard`);
});
+169
View File
@@ -0,0 +1,169 @@
const { expect } = require("chai");
const fs = require("fs");
const path = require("path");
const vm = require("vm");
/**
* Regression tests for the dashboard redesign (Sept 2026):
* - status codes and raw statuses are rendered as sentences,
* - dates spell the month so they are unambiguous across locales,
* - the template no longer uses href="#" anchors for actions,
* - the theme tokens keep muted text above WCAG AA contrast.
*/
const root = path.join(__dirname, "..");
const appJs = fs.readFileSync(path.join(root, "public", "script", "app.js"), "utf8");
const css = fs.readFileSync(path.join(root, "public", "css", "style.css"), "utf8");
const html = fs.readFileSync(path.join(root, "public", "partials", "dashboard.htm"), "utf8");
// Load app.js with a stub `angular` that records filter factories, so the
// filters can be exercised without a browser.
function loadFilters() {
const filters = {};
const chain = new Proxy(
{},
{
get(_, prop) {
if (prop === "filter") {
return (name, factory) => {
filters[name] = Array.isArray(factory) ? factory[factory.length - 1] : factory;
return chain;
};
}
return () => chain;
},
}
);
// jQuery is used at the top level of app.js for a couple of global
// listeners; a self-returning proxy absorbs those calls.
const jq = new Proxy(function () {}, {
get: () => jq,
apply: () => jq,
});
const sandbox = {
angular: { module: () => chain, element: () => ({}) },
$: jq,
jQuery: jq,
window: {},
document: { addEventListener() {}, querySelector: () => null },
localStorage: { getItem: () => null, setItem() {} },
navigator: { language: "en-US" },
console,
setTimeout,
clearTimeout,
setInterval,
clearInterval,
};
sandbox.window = sandbox;
vm.createContext(sandbox);
vm.runInContext(appJs, sandbox, { filename: "app.js" });
return filters;
}
function contrast(hexA, hexB) {
const lum = (hex) => {
const c = hex.replace("#", "").match(/../g).map((x) => parseInt(x, 16) / 255);
const f = (v) => (v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
return 0.2126 * f(c[0]) + 0.7152 * f(c[1]) + 0.0722 * f(c[2]);
};
const [l1, l2] = [lum(hexA), lum(hexB)];
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
function token(block, name) {
const m = block.match(new RegExp(`${name}:\\s*(#[0-9a-fA-F]{6})`));
expect(m, `token ${name} not found`).to.not.equal(null);
return m[1];
}
describe("dashboard UI", function () {
let filters;
before(function () {
filters = loadFilters();
});
describe("statusMsg filter", function () {
it("turns known machine codes into sentences", function () {
expect(filters.statusMsg()("branch_not_found")).to.equal("Branch not found on GitHub");
});
it("turns unknown snake_case codes into a sentence", function () {
expect(filters.statusMsg()("token_revoked_by_user")).to.equal("Token revoked by user");
});
it("leaves free text alone", function () {
expect(filters.statusMsg()("Something odd happened")).to.equal("Something odd happened");
});
});
describe("statusLabel filter", function () {
it("labels in-progress statuses as verbs", function () {
const f = filters.statusLabel();
expect(f("download")).to.equal("Downloading");
expect(f("queue")).to.equal("Queued");
expect(f("ready")).to.equal("Ready");
});
it("title-cases unknown statuses", function () {
expect(filters.statusLabel()("half_done")).to.equal("Half done");
});
});
describe("humanTime filter", function () {
it("spells the month for dates older than two days", function () {
const out = filters.humanTime()("2024-03-09T12:00:00Z");
expect(out).to.match(/^on /);
expect(out).to.match(/Mar/);
expect(out).to.not.match(/\d+\/\d+\/\d+/);
});
it("keeps relative wording for recent dates", function () {
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
expect(filters.humanTime()(twoHoursAgo)).to.equal("2 hours ago");
});
});
describe("template", function () {
it("uses buttons, not href=\"#\" anchors, for row actions", function () {
expect(html).to.not.match(/href="#"/);
expect(html).to.match(/<button type="button" class="dropdown-item dropdown-item-danger"/);
});
it("puts the destructive action after a divider", function () {
const divider = html.indexOf('<div class="dropdown-divider"></div>\n <button type="button" class="dropdown-item dropdown-item-danger"');
expect(divider).to.be.greaterThan(-1);
});
it("labels hidden statuses as hidden, not as active filters", function () {
expect(html).to.match(/Hiding \{\{statusKeyLabels\[f\]\}\}/);
});
it("shows an unlimited quota without a full bar", function () {
expect(html).to.match(/quota-fill" ng-if="!quota\[q\.key\]\.unlimited"/);
expect(html).to.not.match(/bg-success|bg-warning|bg-danger/);
});
it("right-aligns the Views column and marks it sortable", function () {
expect(html).to.match(/class="num"[^>]*>\s*<button type="button" class="sortable"[^>]*ng-click="setSort\('pageView'\)"/);
});
});
describe("theme tokens", function () {
const light = css.slice(css.indexOf("\nbody {"), css.indexOf("--font-serif"));
const dark = css.slice(css.indexOf(".dark-mode {"), css.indexOf("\nbody {"));
it("keeps muted text at or above 4.5:1 in light mode", function () {
expect(contrast(token(light, "--ink-muted"), token(light, "--canvas-bg-color"))).to.be.at.least(4.5);
});
it("keeps muted text at or above 4.5:1 in dark mode", function () {
expect(contrast(token(dark, "--ink-muted"), token(dark, "--canvas-bg-color"))).to.be.at.least(4.5);
});
it("keeps status colours at or above 4.5:1 on both canvases", function () {
for (const [block, name] of [[light, "light"], [dark, "dark"]]) {
const canvas = token(block, "--canvas-bg-color");
for (const t of ["--status-ready", "--status-progress", "--status-error"]) {
expect(contrast(token(block, t), canvas), `${t} in ${name}`).to.be.at.least(4.5);
}
}
});
it("shows a keyboard focus ring instead of removing outlines", function () {
expect(css).to.match(/:focus-visible\s*\{[^}]*outline:\s*2px solid var\(--accent\)/);
expect(css).to.not.match(/\.btn:focus,\s*\.btn:active\s*\{[^}]*outline:\s*none/);
});
it("makes the paper palette win over Bootstrap's !important bg utilities", function () {
expect(css).to.match(/\.progress-bar\.bg-success \{ background: var\(--status-ready\) !important; \}/);
});
});
});