From 3d1f923280f6cbbd7182b6536ae27c7fd544d5a8 Mon Sep 17 00:00:00 2001 From: Alp Date: Sun, 12 Jul 2026 11:41:40 -0700 Subject: [PATCH] Build synthetic aggregate dashboard --- .github/workflows/ci.yml | 6 +- README.md | 14 ++++ data/synthetic_aggregate.json | 25 +++++++ index.html | 50 +++++++++++++ scripts/validate.mjs | 18 +++++ src/dashboard.js | 42 +++++++++++ styles.css | 134 ++++++++++++++++++++++++++++++++++ 7 files changed, 285 insertions(+), 4 deletions(-) create mode 100644 data/synthetic_aggregate.json create mode 100644 index.html create mode 100644 scripts/validate.mjs create mode 100644 src/dashboard.js create mode 100644 styles.css diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 040f889..0fadddf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,5 @@ jobs: - uses: actions/setup-node@v4 with: node-version: "22" - - name: Placeholder - run: | - node --version - echo "Add linting and tests with the first implementation PR." + - name: Validate static dashboard + run: node scripts/validate.mjs diff --git a/README.md b/README.md index 6a0806d..0f297cd 100644 --- a/README.md +++ b/README.md @@ -26,3 +26,17 @@ The dashboard must only consume aggregate, publication-approved data. No raw con ## First Milestone Build a static dashboard using synthetic aggregate data, including suppression rules for cohorts below the publication threshold. + +## Current Prototype + +This repo includes a static synthetic aggregate dashboard: + +```bash +python3 -m http.server 8000 +``` + +Then open `http://127.0.0.1:8000`. + +The dashboard reads `data/synthetic_aggregate.json`, displays aggregate dimension counts, +and suppresses cohorts below `min_public_cohort`. It must not ship raw conversations, +quotes, individual records, schools, locations, or reviewer-only data. diff --git a/data/synthetic_aggregate.json b/data/synthetic_aggregate.json new file mode 100644 index 0000000..4920ced --- /dev/null +++ b/data/synthetic_aggregate.json @@ -0,0 +1,25 @@ +{ + "schema_version": "0.1.0", + "status": "Synthetic draft", + "record_count": 12, + "min_public_cohort": 5, + "generated_at": "2026-07-12", + "dimensions": [ + { + "name": "academic_support", + "count": 8 + }, + { + "name": "emotional_support", + "count": 6 + }, + { + "name": "identity_or_relationships", + "count": 4 + }, + { + "name": "safety_sensitive", + "count": 2 + } + ] +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..cc4decc --- /dev/null +++ b/index.html @@ -0,0 +1,50 @@ + + + + + + Make AI Visible Synthetic Dashboard + + + +
+
+

Synthetic aggregate MVP

+

Make AI Visible dashboard

+

+ Public views should show aggregate, publication-approved patterns only. This + prototype uses synthetic data and suppresses cohorts below the privacy threshold. +

+
+ +
+
+ 0 +

synthetic records

+
+
+ 0 +

minimum cohort threshold

+
+
+ Draft +

data status

+
+
+ +
+

Behavioral dimensions

+
+
+ +
+

Methodology

+

+ No individual conversations, quotes, schools, locations, or small cohorts should + appear in this dashboard. +

+
+
+ + + diff --git a/scripts/validate.mjs b/scripts/validate.mjs new file mode 100644 index 0000000..a56a00a --- /dev/null +++ b/scripts/validate.mjs @@ -0,0 +1,18 @@ +import { readFile } from "node:fs/promises"; + +const aggregate = JSON.parse(await readFile("data/synthetic_aggregate.json", "utf8")); + +if (aggregate.min_public_cohort < 5) { + throw new Error("min_public_cohort must be at least 5 for the MVP demo"); +} + +if (aggregate.dimensions.some((dimension) => typeof dimension.count !== "number")) { + throw new Error("all dimensions need numeric counts"); +} + +const html = await readFile("index.html", "utf8"); +if (!html.includes("src/dashboard.js")) { + throw new Error("dashboard script is not linked"); +} + +console.log("Dashboard prototype validation passed."); diff --git a/src/dashboard.js b/src/dashboard.js new file mode 100644 index 0000000..f3d4616 --- /dev/null +++ b/src/dashboard.js @@ -0,0 +1,42 @@ +async function loadAggregate() { + const response = await fetch("data/synthetic_aggregate.json"); + return response.json(); +} + +function render(data) { + document.querySelector("#record-count").textContent = data.record_count; + document.querySelector("#threshold").textContent = data.min_public_cohort; + document.querySelector("#freshness").textContent = data.status; + + const chart = document.querySelector("#chart"); + chart.replaceChildren(); + + for (const dimension of data.dimensions) { + const row = document.createElement("div"); + row.className = "bar-row"; + + const meta = document.createElement("div"); + meta.className = "bar-meta"; + + const label = document.createElement("strong"); + label.textContent = dimension.name.replaceAll("_", " "); + + const value = document.createElement("span"); + const suppressed = dimension.count < data.min_public_cohort; + value.textContent = suppressed ? "Suppressed" : `${dimension.count} records`; + if (suppressed) value.className = "suppressed"; + + const track = document.createElement("div"); + track.className = "bar-track"; + const fill = document.createElement("div"); + fill.className = "bar-fill"; + fill.style.width = suppressed ? "0%" : `${(dimension.count / data.record_count) * 100}%`; + track.append(fill); + + meta.append(label, value); + row.append(meta, track); + chart.append(row); + } +} + +loadAggregate().then(render); diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..43a5521 --- /dev/null +++ b/styles.css @@ -0,0 +1,134 @@ +:root { + --ink: #17212f; + --muted: #5a6878; + --line: #d1dbe5; + --surface: #ffffff; + --wash: #f1f5f2; + --accent: #3b755f; + --accent-2: #5f6fa3; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--wash); + color: var(--ink); + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +.shell { + width: min(100%, 1040px); + margin: 0 auto; + padding: 28px 16px 40px; +} + +header { + padding: 22px 0; +} + +.eyebrow { + margin: 0 0 8px; + color: var(--accent); + font-size: 0.82rem; + font-weight: 800; + text-transform: uppercase; +} + +h1, +h2, +p { + margin-top: 0; +} + +h1 { + max-width: 760px; + margin-bottom: 12px; + font-size: clamp(2.2rem, 7vw, 4.5rem); + line-height: 0.98; +} + +header p, +.panel p { + max-width: 720px; + color: var(--muted); + line-height: 1.55; +} + +.summary { + display: grid; + gap: 12px; + margin-bottom: 16px; +} + +.summary article, +.panel { + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); +} + +.summary article { + padding: 18px; +} + +.summary span { + display: block; + margin-bottom: 4px; + font-size: 2rem; + font-weight: 850; +} + +.summary p { + margin: 0; + color: var(--muted); +} + +.panel { + margin-top: 16px; + padding: 18px; +} + +.chart { + display: grid; + gap: 14px; +} + +.bar-row { + display: grid; + gap: 6px; +} + +.bar-meta { + display: flex; + justify-content: space-between; + gap: 10px; + color: var(--muted); + font-size: 0.92rem; +} + +.bar-track { + height: 18px; + overflow: hidden; + border-radius: 6px; + background: #e7edf2; +} + +.bar-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent), var(--accent-2)); +} + +.suppressed { + color: #7a5500; + font-weight: 700; +} + +@media (min-width: 760px) { + .summary { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +}