Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,5 @@ jobs:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps chromium
- run: pnpm exec playwright install --with-deps chromium webkit
- run: pnpm test:a11y
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ See participation, completion, scores, and where learners stopped, on your own C

![Proof dashboard — activity detail with completion rate, drop-off funnel, and learner roster](docs/assets/dashboard.png)

The same page in dark mode.

![Proof dashboard in dark mode — the same activity detail, with the biggest drop-off step marked in yellow](docs/assets/dashboard-dark.png)

## How it works

1. Deploy your instance (one click below, or CLI).
Expand Down Expand Up @@ -75,6 +79,11 @@ Anonymous pseudonymous identity is the default. Operators can configure notice o

Read keys are separate from ingest keys, so pages can write results without being able to read them back. The read API provides JSON summaries and paste-ready markdown reports; `llms.txt` documents those endpoints for AI builders, and [docs/api.md](docs/api.md) is the reference.

The dashboard follows your operating system's light or dark appearance by
default. A Theme control in the header pins it to Light or Dark instead, or back
to Auto. The choice is stored per device and per browser, not per account, so it
never travels with your data and never leaves the browser.

## Deploy

**Recommended:** use the Deploy to Cloudflare button. See [docs/deploy.md](docs/deploy.md) for one-click, CLI, EU jurisdiction, and the current limits of regional placement. Production instances must set the `ADMIN_PASSWORD` secret:
Expand Down
Binary file added docs/assets/dashboard-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/dashboard.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/about.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export function About() {
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="/theme.js"></script>
<link rel="stylesheet" href="/dashboard.css" />
<title>About Proof</title>
</head>
Expand Down
24 changes: 16 additions & 8 deletions src/dashboard/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ const VERB_LABELS: Record<string, string> = {
};
const verbLabel = (iri: string) => VERB_LABELS[iri] ?? iri.split("/").pop() ?? iri;

/** A table cell holding only an em dash reads as an empty cell to a screen
reader. Keep the glyph for sighted scanning, say the word to assistive tech. */
function orNone(value: string) {
return value === "\u2014"
? <><span aria-hidden="true">{"\u2014"}</span><span class="prax-visually-hidden">None</span></>
: value;
}

const Q_IRI_RE = /\/q\/([^/]+)$/;
function timelineDetail(row: TimelineRow): string {
if (row.step) return row.step;
Expand Down Expand Up @@ -134,17 +142,17 @@ function FunnelSection(props: {
: "—";
return (
<tr class={i === biggestIdx ? "prax-drop-row" : ""}>
<td title={r.step.startsWith("__") ? undefined : r.step}>{r.label}</td>
<td title={r.step.startsWith("__") ? undefined : r.step}>{i === biggestIdx ? <mark>{r.label}</mark> : r.label}</td>
<td>
<div class="prax-track" aria-hidden="true">
<div class="prax-track-fill" style={`width:${width}%`} data-has-value={r.learners > 0 ? "true" : undefined}></div>
</div>
<span>{String(r.learners)}</span>
</td>
<td>{retention}</td>
<td>{orNone(retention)}</td>
<td>
{drop}
{i === biggestIdx ? <strong> ▼ biggest drop-off</strong> : null}
{orNone(drop)}
{i === biggestIdx ? <>{" "}<mark>▼ biggest drop-off</mark></> : null}
</td>
</tr>
);
Expand Down Expand Up @@ -194,7 +202,7 @@ dashboardRoutes.get("/", async (c) => {
<td>{String(a.starts)}</td>
<td>{String(a.participants)}</td>
<td>{String(a.completions)}</td>
<td>{a.lastActivity ? a.lastActivity.slice(0, 10) : "—"}</td>
<td>{orNone(a.lastActivity ? a.lastActivity.slice(0, 10) : "—")}</td>
</tr>
))}
</tbody>
Expand Down Expand Up @@ -529,7 +537,7 @@ dashboardRoutes.get("/activity", async (c) => {
<span class="prax-badge open">In progress</span>
)}
</td>
<td>{r.scoreRaw !== null ? `${r.scoreRaw} / ${r.scoreMax ?? "?"}` : "—"}</td>
<td>{orNone(r.scoreRaw !== null ? `${r.scoreRaw} / ${r.scoreMax ?? "?"}` : "—")}</td>
<td>{r.lastSeen.slice(0, 10)}</td>
</tr>
))}
Expand Down Expand Up @@ -966,8 +974,8 @@ dashboardRoutes.get("/learner", async (c) => {
<tr>
<td>{row.timestamp.slice(0, 16).replace("T", " ")}</td>
<td>{verbLabel(row.verb)}</td>
<td>{timelineDetail(row)}</td>
<td>{timelineResult(row)}</td>
<td>{orNone(timelineDetail(row))}</td>
<td>{orNone(timelineResult(row))}</td>
</tr>
))}
</tbody>
Expand Down
168 changes: 111 additions & 57 deletions src/dashboard/styles.ts
Original file line number Diff line number Diff line change
@@ -1,107 +1,161 @@
// SPDX-License-Identifier: MIT
// Vendored minimal --prax-* token set. Swap for @praxity/tokens (prax repo,
// packages/tokens) once it is published to npm; variable names match it.
// The light half of every pair is the canonical value from prax/packages/tokens
// (MIT); the dark half is Proof's. Canvas is the page, surface is a quiet panel
// (packages/tokens/design.md). light-dark() resolves against the root's
// color-scheme, so the selector only has to flip that one property.
export const DASHBOARD_CSS = `
:root {
--prax-color-bg: #F9F8F0;
--prax-color-surface: #FFFFFF;
--prax-color-ink: #1A1A1A;
--prax-color-ink-soft: #55534E;
--prax-color-line: #E4E1D6;
--prax-color-accent: #007A63;
--prax-color-accent-soft: #D6F3EC;
--prax-color-warn: #8A4B00;
--prax-color-warn-soft: #FFF6BF;
--prax-color-focus: #1D4ED8; /* deliberately NOT the accent: focus rings must contrast with adjacent teal ink, not blend with it */
--prax-color-accent-2: #00C9A7;
--prax-color-accent-3: #FF48B0;
--prax-color-pink-soft: #FFE0F1;
--prax-radius: 8px;
--prax-offset-shadow: 3px 3px 0 var(--prax-color-accent-soft);
--prax-space: 1rem;
color-scheme: light dark;

--prax-font-body: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--prax-font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
/* Three weights, one job each: body, label, heading. */
--prax-type-body-weight: 400;
--prax-type-heading-weight: 700;
/* Proof-only: the canonical set has no label tier. */
--prax-type-label-weight: 600;
--prax-color-canvas: light-dark(#ffffff, #10151c);
--prax-color-surface: light-dark(#f7f8fa, #1a212b);
--prax-color-text: light-dark(#172033, #e4e9f0);
--prax-color-muted: light-dark(#5a6578, #9fabbd);
--prax-color-border: light-dark(#c8d0dc, #2f3a48);
--prax-color-accent: light-dark(#0f766e, #4ecdb9);
--prax-color-danger: light-dark(#b42318, #ff9d92);
--prax-radius-1: 0.25rem;
--prax-radius-2: 0.5rem;
--prax-shadow-card: 0 1px 2px light-dark(rgb(23 32 51 / 0.08), rgb(0 0 0 / 0.4));

/* Proof-only: reporting states the canonical set has no token for. One hue per
meaning. Teal reads "finished", amber reads "look here", neutral reads
"still going" because in-progress is a state, not an alert. */
--prax-color-accent-surface: light-dark(#cfeae4, #12463f);
/* Theme-independent on purpose: the drop-off row is a highlighter band, so it
stays pale yellow on a dark page rather than turning olive. Hue is what makes
this read as marker ink rather than cream: below ~50deg it slides into the
beige wedge, above ~57deg it goes acid. */
--prax-color-warning-surface: #f8e56a;
--prax-color-neutral-surface: light-dark(#e3e8ee, #262f3b);
/* Bar track. Chosen so the accent fill clears 3:1 against it, which is what
makes the filled proportion legible, not the track edge against the panel. */
--prax-color-track: light-dark(#bcc6d3, #3f4b5a);
/* Deliberately NOT the accent: focus rings must contrast with adjacent teal
ink, not blend with it. */
--prax-color-focus: light-dark(#1d4ed8, #8ab8ff);
}
/* The selector pins a scheme; without it the OS preference wins. */
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }
* { box-sizing: border-box; }
/* On the root, not just body: background propagation from body leaves the root
transparent, which makes contrast tooling read the page as white. */
html { background: var(--prax-color-canvas); }
body {
margin: 0; background: var(--prax-color-bg); color: var(--prax-color-ink);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 16px; line-height: 1.55; font-weight: 400;
margin: 0; background: var(--prax-color-canvas); color: var(--prax-color-text);
font-family: var(--prax-font-body);
font-size: 16px; line-height: 1.55; font-weight: var(--prax-type-body-weight);
}
a { color: var(--prax-color-accent); }
:focus-visible { outline: 3px solid var(--prax-color-focus); outline-offset: 2px; }
input, select, button { border: 1px solid var(--prax-color-ink-soft); border-radius: 4px; padding: 0.35rem 0.6rem; background: var(--prax-color-surface); font: inherit; }
input, select, button { border: 1px solid var(--prax-color-muted); border-radius: var(--prax-radius-1); padding: 0.35rem 0.6rem; font: inherit; }
/* Inputs and selects are left to color-scheme: WebKit ignores author color on a
native menulist, so forcing a background there yields black ink on dark.
Buttons do honour author colors, and need them, because a button's UA default
background is silver and .prax-danger text does not clear 4.5:1 against it. */
button { background: var(--prax-color-surface); color: var(--prax-color-text); }
code, pre { overflow-wrap: anywhere; }
pre { max-width: 100%; white-space: pre-wrap; }
.prax-skip {
position: absolute; left: -999px; top: 0; background: var(--prax-color-surface);
position: absolute; left: -999px; top: 0; background: var(--prax-color-canvas);
border: 1px solid var(--prax-color-border);
padding: 0.5rem 1rem; z-index: 10;
}
.prax-skip:focus { left: 0; }
header.prax-top {
border-bottom: 3px solid var(--prax-color-accent); background: var(--prax-color-surface);
padding: 0.75rem 1.25rem;
display: flex; flex-wrap: wrap; gap: 0.5rem 1rem;
align-items: center; justify-content: space-between;
}
header.prax-top strong { font-weight: 800; }
/* The control only works with script, so it only exists with script. */
.prax-theme { display: none; }
:root[data-js] .prax-theme { display: flex; align-items: center; gap: 0.5rem; }
.prax-theme label { font-size: 0.8rem; color: var(--prax-color-muted); }
.prax-theme select { padding: 0.3rem 0.4rem; font-size: 0.8rem; }
header.prax-top strong { font-weight: var(--prax-type-heading-weight); }
header.prax-top nav { display: inline; }
header.prax-top [aria-current="page"] { color: var(--prax-color-ink); font-weight: 700; }
header.prax-top [aria-current="page"] { color: var(--prax-color-text); font-weight: var(--prax-type-label-weight); }
main { max-width: 960px; margin: 0 auto; padding: 1.5rem 1.25rem 4rem; }
h1, h2 { font-weight: 800; }
h1 { font-size: 1.5rem; margin: 0.5rem 0 1rem; }
h1::after { content: ""; display: block; width: 3.5rem; height: 4px; background: var(--prax-color-accent-2); margin-top: 0.35rem; border-radius: 2px; }
h2 { font-size: 1.1rem; margin: 2rem 0 0.75rem; }
table { width: 100%; border-collapse: collapse; background: var(--prax-color-surface);
border: 1px solid var(--prax-color-line); border-radius: var(--prax-radius);
box-shadow: var(--prax-offset-shadow); }
h1, h2 { font-weight: var(--prax-type-heading-weight); }
h1 { font-size: 2rem; letter-spacing: -0.015em; margin: 0.5rem 0 1rem; }
h2 { font-size: 1.25rem; margin: 2rem 0 0.75rem; }
table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; background: var(--prax-color-surface);
border: 1px solid var(--prax-color-border); border-radius: var(--prax-radius-2);
box-shadow: var(--prax-shadow-card); }
.prax-table-wrap { max-width: 100%; overflow-x: auto; }
caption { text-align: left; font-size: 0.85rem; color: var(--prax-color-ink-soft); margin-bottom: 0.5rem; }
caption { text-align: left; font-size: 0.8rem; color: var(--prax-color-muted); margin-bottom: 0.5rem; }
th { text-align: left; font-size: 0.8rem; letter-spacing: 0.04em; text-transform: uppercase;
color: var(--prax-color-ink-soft); font-weight: 500; padding: 0.6rem 0.75rem; border-bottom: 2px solid var(--prax-color-line); }
td { padding: 0.6rem 0.75rem; border-bottom: 1px solid var(--prax-color-line); }
color: var(--prax-color-muted); font-weight: var(--prax-type-label-weight); padding: 0.6rem 0.75rem; border-bottom: 2px solid var(--prax-color-border); }
td { padding: 0.6rem 0.75rem; border-bottom: 1px solid var(--prax-color-border); }
.prax-stats { display: flex; gap: 1rem; flex-wrap: wrap; margin: 1rem 0 1.5rem; }
.prax-stat { flex: 1 1 140px; background: var(--prax-color-surface);
border: 1px solid var(--prax-color-line); border-radius: var(--prax-radius); padding: 0.9rem 1rem;
box-shadow: var(--prax-offset-shadow); }
.prax-stat b { display: block; font-size: 1.6rem; }
.prax-stat span { font-size: 0.8rem; color: var(--prax-color-ink-soft); text-transform: uppercase; letter-spacing: 0.05em; }
.prax-stat-hero { box-shadow: 3px 3px 0 var(--prax-color-pink-soft); }
.prax-stat-hero b { font-size: 2.4rem; }
.prax-stat .prax-sub { display:block; font-size: 0.8rem; color: var(--prax-color-ink-soft); text-transform: none; letter-spacing: 0; margin-top: 0.25rem; }
.prax-badge { display: inline-block; font-size: 0.78rem; font-weight: 600;
border: 1px solid var(--prax-color-border); border-radius: var(--prax-radius-2); padding: 0.9rem 1rem;
box-shadow: var(--prax-shadow-card); }
.prax-stat b { display: block; font-size: 1.5rem; font-weight: var(--prax-type-heading-weight); font-family: var(--prax-font-mono); letter-spacing: -0.02em; }
.prax-stat span { font-size: 0.8rem; color: var(--prax-color-muted); text-transform: uppercase; letter-spacing: 0.05em; }
/* The headline number earns its emphasis from size alone. */
.prax-stat-hero b { font-size: 1.75rem; }
.prax-stat .prax-sub { display:block; font-size: 0.8rem; color: var(--prax-color-muted); text-transform: none; letter-spacing: 0; margin-top: 0.25rem; }
.prax-badge { display: inline-block; font-size: 0.8rem; font-weight: var(--prax-type-label-weight);
padding: 0.1rem 0.6rem; border-radius: 999px; }
.prax-badge.done { background: var(--prax-color-accent-soft); color: var(--prax-color-ink); }
.prax-badge.open { background: var(--prax-color-warn-soft); color: var(--prax-color-ink); }
.prax-badge.done { background: var(--prax-color-accent-surface); color: var(--prax-color-text); }
.prax-badge.open { background: var(--prax-color-neutral-surface); color: var(--prax-color-text); }
.prax-bars { display: grid; gap: 0.35rem; margin: 0.75rem 0; }
.prax-bar { display: grid; grid-template-columns: 7rem 1fr auto; gap: 0.75rem; align-items: center; font-size: 0.85rem; }
.prax-bar .fill { background: var(--prax-color-accent); height: 1rem; border-radius: 3px; }
.prax-bar { display: grid; grid-template-columns: 7rem 1fr auto; gap: 0.75rem; align-items: center; font-size: 0.8rem; }
.prax-bar .fill { background: var(--prax-color-accent); height: 1rem; border-radius: var(--prax-radius-1); }
.prax-bar .fill.has-value { min-width: 2px; }
.prax-track { width: 9rem; height: 0.9rem; background: var(--prax-color-line); border-radius: 3px; display: inline-block; vertical-align: middle; margin-right: 0.5rem; }
.prax-track-fill { height: 100%; background: var(--prax-color-accent); border-radius: 3px; }
.prax-track { width: 9rem; height: 0.9rem; background: var(--prax-color-track); border-radius: var(--prax-radius-1); display: inline-block; vertical-align: middle; margin-right: 0.5rem; }
.prax-track-fill { height: 100%; background: var(--prax-color-accent); border-radius: var(--prax-radius-1); }
.prax-track-fill[data-has-value="true"] { min-width: 2px; }
.prax-drop-row td { background: var(--prax-color-pink-soft); }
.prax-soft { color: var(--prax-color-ink-soft); }
.prax-empty { background: var(--prax-color-surface); border: 1px dashed var(--prax-color-line);
border-radius: var(--prax-radius); padding: 2rem; text-align: center; color: var(--prax-color-ink-soft); }
/* A highlighter marks words, not the page width. The stroke sits on the phrase
that explains the row; the row itself keeps only a trace of the same yellow so
it stays findable when scanning the left edge. Filling the whole row was too
loud on a dark page, and put the teal bar directly on saturated yellow. */
.prax-drop-row td { background: color-mix(in srgb, var(--prax-color-warning-surface) 14%, transparent); }
/* color-scheme: light makes the inherited ink token resolve dark, so the stroke
reads the same in either theme. */
mark { color-scheme: light; background: var(--prax-color-warning-surface); color: var(--prax-color-text);
font-weight: var(--prax-type-label-weight); padding: 0.05rem 0.3rem; border-radius: var(--prax-radius-1); }
.prax-soft { color: var(--prax-color-muted); }
.prax-empty { background: var(--prax-color-surface); border: 1px dashed var(--prax-color-border);
border-radius: var(--prax-radius-2); padding: 2rem; text-align: center; color: var(--prax-color-muted); }
form.prax-form { display: grid; grid-template-columns: minmax(10rem, 14rem) minmax(12rem, 1fr); gap: 0.75rem 1rem; align-items: center; max-width: 48rem; }
form.prax-form input, form.prax-form select { width: 100%; }
form.prax-form .prax-form-actions { grid-column: 2; }
.prax-field-help { grid-column: 2; margin: -0.5rem 0 0; color: var(--prax-color-ink-soft); font-size: 0.9rem; }
.prax-field-help { grid-column: 2; margin: -0.5rem 0 0; color: var(--prax-color-muted); }
.prax-message, .prax-error {
background: var(--prax-color-surface); border-left: 4px solid var(--prax-color-accent);
padding: 0.75rem 1rem; margin: 1rem 0;
}
.prax-error { border-left-color: #8B1E1E; }
.prax-error { border-left-color: var(--prax-color-danger); }
.prax-error h2 { margin-top: 0; }
.prax-actions { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
.prax-landing { max-width: 52rem; padding-top: clamp(2.5rem, 8vw, 5rem); }
.prax-landing h1 { max-width: 15ch; font-size: clamp(2rem, 7vw, 3.5rem); line-height: 1.05; letter-spacing: -0.035em; }
.prax-lede { max-width: 42rem; color: var(--prax-color-muted); font-size: 1.15rem; }
.prax-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: 1rem; margin: 2rem 0; }
.prax-fact, .prax-contact { background: var(--prax-color-surface); border: 1px solid var(--prax-color-border); border-radius: var(--prax-radius-2); padding: 1.2rem; }
.prax-fact h2, .prax-contact h2 { margin-top: 0; }
.prax-primary { display: inline-block; background: var(--prax-color-accent); color: var(--prax-color-canvas); border-radius: var(--prax-radius-1); padding: 0.7rem 1rem; font-weight: var(--prax-type-heading-weight); text-decoration: none; }
.prax-visually-hidden {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
.prax-danger { color: #8B1E1E; }
.prax-danger { color: var(--prax-color-danger); }
@media (max-width: 640px) {
header.prax-top { line-height: 2; }
main { padding: 1rem 0.75rem 3rem; }
form.prax-form { grid-template-columns: 1fr; }
form.prax-form .prax-form-actions { grid-column: 1; }
.prax-field-help { grid-column: 1; }
.prax-stats { gap: 0.6rem; }
.prax-stat { flex-basis: calc(50% - 0.6rem); }
.prax-track { width: 5rem; }
Expand Down
Loading
Loading