diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 040f889..953f139 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 prototype + run: node scripts/validate.mjs diff --git a/README.md b/README.md index 4f2c559..614f466 100644 --- a/README.md +++ b/README.md @@ -26,3 +26,22 @@ The frontend should never persist raw conversation exports beyond the active upl ## First Milestone Build a static, mobile-first upload prototype wired to a mock anonymization endpoint, with consent copy and export instructions ready for review. + +## Current Prototype + +This repo now includes a static upload prototype that can be opened directly in a browser: + +```bash +open index.html +``` + +The prototype includes: + +- Plain-language consent and privacy reminders. +- File selection for supported export-like formats: JSON, TXT, CSV, and ZIP. +- Validation, ready, and mock-success states. +- Platform guidance for ChatGPT, Claude, Gemini, and Copilot. +- A mock anonymization result that documents the intended `POST /anonymize` boundary. + +No file is uploaded or persisted by this static prototype. It is meant to validate the +contributor-facing flow before connecting to a deployed anonymization service. diff --git a/index.html b/index.html new file mode 100644 index 0000000..70467a2 --- /dev/null +++ b/index.html @@ -0,0 +1,73 @@ + + + + + + Make AI Visible Upload Prototype + + + +
+
+

Make AI Visible MVP

+

Donate an AI conversation safely

+

+ Upload a conversation export to test the privacy-first submission flow. This + prototype uses synthetic local handling and does not send files anywhere. +

+
+ +
+ + + +
+ +
+

Upload export

+
+ +

Choose a ChatGPT, Claude, Gemini, or Copilot export file.

+
+ +

Waiting for consent and a file.

+
+ +
+

Export guidance

+
+
+

ChatGPT

+

Use the data export option from settings and upload the downloaded export file.

+
+
+

Claude

+

Export or copy a conversation into a supported text or JSON file when available.

+
+
+

Gemini

+

Use account export tools where available and avoid sharing account credentials.

+
+
+

Copilot

+

Export conversation history only if your account and product settings allow it.

+
+
+
+ +
+

Mock anonymization result

+
No file processed yet.
+
+
+ + + diff --git a/scripts/validate.mjs b/scripts/validate.mjs new file mode 100644 index 0000000..fc5f81d --- /dev/null +++ b/scripts/validate.mjs @@ -0,0 +1,26 @@ +import { readFile } from "node:fs/promises"; + +const requiredFiles = ["index.html", "styles.css", "src/app.js"]; + +for (const path of requiredFiles) { + const source = await readFile(path, "utf8"); + if (!source.trim()) { + throw new Error(`${path} is empty`); + } +} + +const html = await readFile("index.html", "utf8"); +for (const needle of ["consent", "file-input", "submit-button", "Mock anonymization result"]) { + if (!html.includes(needle)) { + throw new Error(`index.html is missing ${needle}`); + } +} + +const script = await readFile("src/app.js", "utf8"); +for (const needle of ["supportedExtensions", "raw_file_persisted", "addEventListener"]) { + if (!script.includes(needle)) { + throw new Error(`src/app.js is missing ${needle}`); + } +} + +console.log("Portal prototype validation passed."); diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..06b8a2c --- /dev/null +++ b/src/app.js @@ -0,0 +1,62 @@ +const consent = document.querySelector("#consent"); +const fileInput = document.querySelector("#file-input"); +const fileSummary = document.querySelector("#file-summary"); +const submitButton = document.querySelector("#submit-button"); +const statusText = document.querySelector("#status"); +const result = document.querySelector("#result"); + +const supportedExtensions = [".json", ".txt", ".csv", ".zip"]; + +function selectedFile() { + return fileInput.files && fileInput.files.length > 0 ? fileInput.files[0] : null; +} + +function isSupported(file) { + return supportedExtensions.some((extension) => file.name.toLowerCase().endsWith(extension)); +} + +function updateState() { + const file = selectedFile(); + const hasConsent = consent.checked; + + if (!file) { + fileSummary.textContent = "Choose a ChatGPT, Claude, Gemini, or Copilot export file."; + statusText.textContent = hasConsent ? "Waiting for a file." : "Waiting for consent and a file."; + submitButton.disabled = true; + return; + } + + fileSummary.textContent = `${file.name} (${Math.ceil(file.size / 1024)} KB)`; + + if (!isSupported(file)) { + statusText.textContent = "Unsupported file type. Use JSON, TXT, CSV, or ZIP."; + submitButton.disabled = true; + return; + } + + statusText.textContent = hasConsent ? "Ready to validate." : "Consent is required before upload."; + submitButton.disabled = !hasConsent; +} + +function mockAnonymize() { + const file = selectedFile(); + if (!file) return; + + statusText.textContent = "Validated locally. Mock anonymization complete."; + result.textContent = JSON.stringify( + { + upload_status: "accepted_for_mock_processing", + file_name: file.name, + anonymization_service: "POST /anonymize", + raw_file_persisted: false, + next_review_step: "privacy review before scoring or publication", + }, + null, + 2, + ); +} + +consent.addEventListener("change", updateState); +fileInput.addEventListener("change", updateState); +submitButton.addEventListener("click", mockAnonymize); +updateState(); diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..b8a27bf --- /dev/null +++ b/styles.css @@ -0,0 +1,195 @@ +:root { + color-scheme: light; + --ink: #17212f; + --muted: #526173; + --line: #cfd8e3; + --surface: #ffffff; + --wash: #eef4f8; + --accent: #2f7d6d; + --accent-strong: #155a4c; + --warn: #8a5a00; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + color: var(--ink); + background: var(--wash); + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +.shell { + width: min(100%, 920px); + margin: 0 auto; + padding: 24px 16px 40px; +} + +.intro { + padding: 28px 0 18px; +} + +.eyebrow { + margin: 0 0 8px; + color: var(--accent-strong); + font-size: 0.85rem; + font-weight: 700; + text-transform: uppercase; +} + +h1, +h2, +h3, +p { + margin-top: 0; +} + +h1 { + max-width: 720px; + margin-bottom: 12px; + font-size: clamp(2.2rem, 8vw, 4.6rem); + line-height: 0.95; +} + +h2 { + margin-bottom: 16px; + font-size: 1.35rem; +} + +h3 { + margin-bottom: 8px; + font-size: 1rem; +} + +.intro p { + max-width: 680px; + color: var(--muted); + font-size: 1.08rem; + line-height: 1.55; +} + +.panel { + margin-top: 16px; + padding: 18px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); +} + +.checklist { + display: grid; + gap: 10px; + margin: 0 0 18px; + padding-left: 20px; + color: var(--muted); +} + +.consent { + display: flex; + gap: 10px; + align-items: flex-start; + font-weight: 650; + line-height: 1.4; +} + +.consent input { + width: 20px; + height: 20px; + margin-top: 1px; + accent-color: var(--accent); +} + +.dropzone { + display: grid; + gap: 12px; + min-height: 136px; + place-items: center; + padding: 22px; + border: 2px dashed var(--line); + border-radius: 8px; + background: #f8fafc; + text-align: center; +} + +.dropzone input { + width: 100%; + max-width: 360px; +} + +.dropzone p { + margin-bottom: 0; + color: var(--muted); +} + +button { + width: 100%; + min-height: 48px; + margin-top: 14px; + border: 0; + border-radius: 8px; + color: #fff; + background: var(--accent); + font: inherit; + font-weight: 750; + cursor: pointer; +} + +button:disabled { + color: #6a7685; + background: #d8e0e8; + cursor: not-allowed; +} + +.status { + margin: 12px 0 0; + color: var(--warn); + font-weight: 650; +} + +.guidance-grid { + display: grid; + gap: 12px; +} + +.guidance-grid article { + padding: 14px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fbfcfd; +} + +.guidance-grid p { + margin-bottom: 0; + color: var(--muted); + line-height: 1.45; +} + +pre { + min-height: 144px; + margin: 0; + overflow: auto; + padding: 14px; + border-radius: 8px; + background: #101820; + color: #d9f7eb; + font-size: 0.9rem; + line-height: 1.5; +} + +@media (min-width: 720px) { + .shell { + padding-top: 36px; + } + + .guidance-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + button { + width: auto; + min-width: 240px; + } +}