diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5043b36..7d8cd10 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index 975cad4..e3cc5a0 100644 --- a/README.md +++ b/README.md @@ -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). @@ -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: diff --git a/docs/assets/dashboard-dark.png b/docs/assets/dashboard-dark.png new file mode 100644 index 0000000..394bdfd Binary files /dev/null and b/docs/assets/dashboard-dark.png differ diff --git a/docs/assets/dashboard.png b/docs/assets/dashboard.png index 0ba50af..d3f1d6a 100644 Binary files a/docs/assets/dashboard.png and b/docs/assets/dashboard.png differ diff --git a/src/about.tsx b/src/about.tsx index 97a4895..40dbe16 100644 --- a/src/about.tsx +++ b/src/about.tsx @@ -6,6 +6,7 @@ export function About() { + About Proof diff --git a/src/dashboard/routes.tsx b/src/dashboard/routes.tsx index bae5e2f..e96a36c 100644 --- a/src/dashboard/routes.tsx +++ b/src/dashboard/routes.tsx @@ -66,6 +66,14 @@ const VERB_LABELS: Record = { }; 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" + ? <>None + : value; +} + const Q_IRI_RE = /\/q\/([^/]+)$/; function timelineDetail(row: TimelineRow): string { if (row.step) return row.step; @@ -134,17 +142,17 @@ function FunnelSection(props: { : "—"; return ( - {r.label} + {i === biggestIdx ? {r.label} : r.label} {String(r.learners)} - {retention} + {orNone(retention)} - {drop} - {i === biggestIdx ? ▼ biggest drop-off : null} + {orNone(drop)} + {i === biggestIdx ? <>{" "}▼ biggest drop-off : null} ); @@ -194,7 +202,7 @@ dashboardRoutes.get("/", async (c) => { {String(a.starts)} {String(a.participants)} {String(a.completions)} - {a.lastActivity ? a.lastActivity.slice(0, 10) : "—"} + {orNone(a.lastActivity ? a.lastActivity.slice(0, 10) : "—")} ))} @@ -529,7 +537,7 @@ dashboardRoutes.get("/activity", async (c) => { In progress )} - {r.scoreRaw !== null ? `${r.scoreRaw} / ${r.scoreMax ?? "?"}` : "—"} + {orNone(r.scoreRaw !== null ? `${r.scoreRaw} / ${r.scoreMax ?? "?"}` : "—")} {r.lastSeen.slice(0, 10)} ))} @@ -966,8 +974,8 @@ dashboardRoutes.get("/learner", async (c) => { {row.timestamp.slice(0, 16).replace("T", " ")} {verbLabel(row.verb)} - {timelineDetail(row)} - {timelineResult(row)} + {orNone(timelineDetail(row))} + {orNone(timelineResult(row))} ))} diff --git a/src/dashboard/styles.ts b/src/dashboard/styles.ts index ade89a5..ca97456 100644 --- a/src/dashboard/styles.ts +++ b/src/dashboard/styles.ts @@ -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; } diff --git a/src/dashboard/ui.tsx b/src/dashboard/ui.tsx index 698f49f..d2c525e 100644 --- a/src/dashboard/ui.tsx +++ b/src/dashboard/ui.tsx @@ -14,12 +14,14 @@ export function Layout(props: PropsWithChildren<{ {props.title} · Proof + Skip to content
+
Proof {" — "} +
+
+ + +
{props.children}
@@ -61,3 +72,26 @@ export function StatCard(props: { label: string; value: string; sub?: string; he ); } + +export const THEME_JS = `(function () { + var root = document.documentElement; + root.dataset.js = "1"; + var stored = null; + try { stored = localStorage.getItem("prax-theme"); } catch (e) {} + if (stored !== "light" && stored !== "dark") stored = null; + if (stored) root.dataset.theme = stored; + document.addEventListener("DOMContentLoaded", function () { + var sel = document.getElementById("prax-theme"); + if (!sel) return; + sel.value = stored || "auto"; + sel.addEventListener("change", function () { + var v = sel.value; + if (v === "auto") delete root.dataset.theme; + else root.dataset.theme = v; + try { + if (v === "auto") localStorage.removeItem("prax-theme"); + else localStorage.setItem("prax-theme", v); + } catch (e) {} + }); + }); +})();`; diff --git a/src/index.ts b/src/index.ts index 2a7d93d..3746c36 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,7 @@ import { LLMS_TXT } from "./llms"; import { aboutHandler } from "./about"; import { landingHandler } from "./landing"; import { DASHBOARD_CSS } from "./dashboard/styles"; -import { DASHBOARD_JS } from "./dashboard/ui"; +import { DASHBOARD_JS, THEME_JS } from "./dashboard/ui"; import { dashboardRoutes } from "./dashboard/routes"; import { apiRoutes } from "./api/routes"; import { privacyHandler } from "./privacy"; @@ -271,6 +271,12 @@ app.get("/dashboard.js", (c) => "Cache-Control": "public, max-age=300", }), ); +app.get("/theme.js", (c) => + c.body(THEME_JS, 200, { + "Content-Type": "text/javascript; charset=utf-8", + "Cache-Control": "public, max-age=300", + }), +); app.use("/dashboard/*", async (c, next) => { await next(); c.res.headers.set("Cache-Control", "no-store"); diff --git a/src/landing.tsx b/src/landing.tsx index a10731b..6d5dbb8 100644 --- a/src/landing.tsx +++ b/src/landing.tsx @@ -1,32 +1,76 @@ // SPDX-License-Identifier: MIT import type { Context } from "hono"; +import type { Env } from "./env"; +import { D1Storage } from "./storage/d1"; +import type { InstanceSettings } from "./storage/types"; -export function Landing() { +export function Landing(props: { settings: InstanceSettings }) { + const operator = props.settings.operatorName.trim(); + const title = operator ? `Learning results for ${operator}` : "Learning activity results"; return ( + + - Proof + {title} · Praxity Proof Skip to content -
-

Proof

-

- This site collects learning results for activities its owner runs. Learner data stays on the owner's own Cloudflare account. +

+ Praxity Proof +
+ Operator access +
+ + +
+
+
+
+

{title}

+

+ {operator + ? `${operator} uses Praxity Proof to record participation and results from some learning activities it provides.` + : "This Praxity Proof instance records participation and results from some learning activities."} +

+ +
+
+

What may be recorded

+

Your learner identifier, activity progress, answers, completion, scores, and duration. The exact information depends on the activity.

+
+
+

How long it is kept

+

Learning statements on this instance are retained for {String(props.settings.retentionDays)} days, then deleted.

+
+
+ +
+

Questions about your data?

+

{props.settings.privacyContact + ? <>Contact {operator || "the activity operator"} at {props.settings.privacyContact}. + : "Ask the activity operator how to make a privacy request."}

+
+ +

+ Read this instance's privacy notice + {props.settings.privacyUrl ? ( + Operator's full privacy policy + ) : null}

-
); } -export function landingHandler(c: Context) { - return c.html(, 200); +export async function landingHandler(c: Context<{ Bindings: Env }>) { + const settings = await new D1Storage(c.env.DB).getSettings(); + return c.html(, 200, { "Cache-Control": "no-store" }); } diff --git a/src/privacy.tsx b/src/privacy.tsx index 9d86682..d921c0a 100644 --- a/src/privacy.tsx +++ b/src/privacy.tsx @@ -11,6 +11,7 @@ function PrivacyPage(props: { settings: InstanceSettings }) { + Learning tracking and privacy · Proof diff --git a/test/a11y/dashboard.a11y.spec.ts b/test/a11y/dashboard.a11y.spec.ts index a3bea2f..d174eea 100644 --- a/test/a11y/dashboard.a11y.spec.ts +++ b/test/a11y/dashboard.a11y.spec.ts @@ -60,6 +60,7 @@ test("activity list has no axe violations", async ({ page }) => { test("activity detail has no axe violations", async ({ page }) => { await page.goto(`/dashboard/activity?iri=${encodeURIComponent(IRI)}`); await expectNoViolations(page); + await expect(page.getByRole("cell", { name: "None", exact: true }).first()).toBeVisible(); const link = page.locator("table a").first(); learnerHref = (await link.getAttribute("href")) ?? ""; expect(learnerHref).toContain("/dashboard/learner"); @@ -82,6 +83,32 @@ test("settings page has no axe violations", async ({ page }) => { await expectNoViolations(page); }); +test("theme selector pins a scheme, persists it, and stays accessible in dark", async ({ page }) => { + await page.goto("/dashboard"); + const theme = page.getByLabel("Theme"); + await expect(page.locator("html")).not.toHaveAttribute("data-theme", /.*/); + + await theme.selectOption("dark"); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + + // The preference is per-device, so it has to survive navigation to a page + // that carries the theme script but has no selector of its own. + await page.goto(`/dashboard/activity?iri=${encodeURIComponent(IRI)}`); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + await expect(theme).toHaveValue("dark"); + await expectNoViolations(page); + + await page.goto("/about"); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + await expectNoViolations(page); + + await page.goto("/dashboard"); + await theme.selectOption("auto"); + await expect(page.locator("html")).not.toHaveAttribute("data-theme", /.*/); + await page.reload(); + await expect(page.locator("html")).not.toHaveAttribute("data-theme", /.*/); +}); + test("keyboard reveals and activates the skip link", async ({ page, browserName }) => { await page.goto("/dashboard"); await page.keyboard.press(browserName === "webkit" ? "Alt+Tab" : "Tab"); @@ -140,7 +167,7 @@ test("destructive actions lead to accessible review screens", async ({ page }) = await page.goto(`/dashboard/activity?iri=${encodeURIComponent(IRI)}`); await page.locator("table a").first().click(); - await page.getByRole("link", { name: "Review deletion of this learner and all statements" }).click(); + await page.getByRole("link", { name: "Review deletion of this learner and all statements" }).press("Enter"); await expect(page.getByRole("heading", { name: /^Delete .+\?$/ })).toBeVisible(); await expect(page.getByRole("link", { name: "Cancel and return to learner" })).toBeVisible(); await expectNoViolations(page); diff --git a/test/dashboard.test.ts b/test/dashboard.test.ts index 31d220c..9198ba2 100644 --- a/test/dashboard.test.ts +++ b/test/dashboard.test.ts @@ -27,7 +27,7 @@ describe("dashboard shell", () => { const res = await SELF.fetch("https://proof.test/dashboard.css"); expect(res.status).toBe(200); expect(res.headers.get("Content-Type")).toBe("text/css; charset=utf-8"); - expect(await res.text()).toContain("--prax-color-bg"); + expect(await res.text()).toContain("--prax-color-canvas"); }); }); diff --git a/test/funnel.test.ts b/test/funnel.test.ts index e4372df..e540aa7 100644 --- a/test/funnel.test.ts +++ b/test/funnel.test.ts @@ -95,7 +95,7 @@ describe("funnel section on activity detail", () => { expect(html).toContain("Finished"); expect(html).toContain("Intro"); expect(html).toContain("▼ biggest drop-off"); - expect(html).toMatch(/\s*Wrap up<\/td>[\s\S]*?▼ biggest drop-off/); + expect(html).toMatch(/\s*[\s\S]*?Wrap up[\s\S]*?▼ biggest drop-off/); }); it("marks the largest percentage drop, even when a larger absolute loss appears earlier", async () => { @@ -108,7 +108,7 @@ describe("funnel section on activity detail", () => { expect(html).toContain("−5 (50%)"); expect(html).toContain("Filter review"); expect(html).toContain("−4 (80%)"); - expect(html).toMatch(/\s*Broad start<\/td>[\s\S]*?−4 \(80%\)[\s\S]*?▼ biggest drop-off/); + expect(html).toMatch(/\s*[\s\S]*?Broad start[\s\S]*?−4 \(80%\)[\s\S]*?▼ biggest drop-off/); }); it("uses identical fixed-track percentages for equal-count steps", async () => { @@ -118,7 +118,7 @@ describe("funnel section on activity detail", () => { ); const html = await res.text(); const widthFor = (label: string) => { - const row = new RegExp(`]*>\\s*]*>${label}[\\s\\S]*?
]*>\\s*]*>(?:)?${label}(?:)?[\\s\\S]*?
{ - it("serves the public landing page without auth or xAPI version headers", async () => { + it("serves a public instance summary without auth or xAPI version headers", async () => { const res = await SELF.fetch("https://proof.test/"); expect(res.status).toBe(200); + expect(res.headers.get("Cache-Control")).toBe("no-store"); expect(res.headers.get("Content-Type")).toContain("text/html"); const body = await res.text(); expect(body).toContain('href="/dashboard"'); - expect(body).toContain('href="/about"'); - expect(body).toContain("This site collects learning results for activities its owner runs."); - expect(body).toContain("Learner data stays on the owner's own Cloudflare account."); - expect(body).not.toContain("conformant LRS"); + expect(body).toContain('href="/privacy"'); + expect(body).toContain("Learning activity results"); + expect(body).toContain("retained for 365 days"); + expect(body).toContain("Operator access"); + expect(body.indexOf("Operator access")).toBeLessThan(body.indexOf(" { + const storage = new D1Storage(env.DB); + const current = await storage.getSettings(); + await storage.updateSettings({ + ...current, + operatorName: "Example Learning Co-op", + privacyUrl: "https://learn.example/privacy", + privacyContact: "privacy@learn.example", + retentionDays: 90, + }); + + const res = await SELF.fetch("https://proof.test/"); + const body = await res.text(); + expect(body).toContain("Learning results for Example Learning Co-op"); + expect(body).toContain("Example Learning Co-op uses Praxity Proof to record participation and results"); + expect(body).toContain("retained for 90 days"); + expect(body).toContain("privacy@learn.example"); + expect(body).toContain('href="https://learn.example/privacy"'); + }); }); describe("GET /about", () => { diff --git a/test/screenshot/capture.spec.ts b/test/screenshot/capture.spec.ts index 16584b3..08bc0d7 100644 --- a/test/screenshot/capture.spec.ts +++ b/test/screenshot/capture.spec.ts @@ -168,6 +168,9 @@ test("captures the dashboard activity detail screenshot", async ({ page, request await expect(page.getByRole("heading", { name: "Fractions quiz" })).toBeVisible(); mkdirSync("docs/assets", { recursive: true }); await page.screenshot({ path: "docs/assets/dashboard.png", fullPage: false }); + await page.selectOption("#prax-theme", "dark"); + await page.screenshot({ path: "docs/assets/dashboard-dark.png", fullPage: false }); + await page.selectOption("#prax-theme", "auto"); mkdirSync("test-results", { recursive: true }); await page.setViewportSize({ width: 390, height: 844 }); await page.screenshot({ path: "test-results/mobile-dashboard.png", fullPage: false });