added the readme

This commit is contained in:
2026-08-22 17:10:31 -04:00
commit 6788ffb88b
18 changed files with 2501 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.env
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+16
View File
@@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and Oxlint's TypeScript related rules in your project.
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<title>ToS Scanner</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+1262
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "ignitionhacks",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.4",
"oxlint": "^1.75.0",
"vite": "^8.2.0"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+604
View File
@@ -0,0 +1,604 @@
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ---------- Header ---------- */
.header {
padding: 56px 24px 0;
max-width: 720px;
margin: 0 auto;
width: 100%;
}
.logo {
margin: 0;
font-size: 20px;
font-weight: 600;
letter-spacing: -0.02em;
color: var(--text);
}
.tagline {
margin: 6px 0 0;
font-size: 14px;
font-weight: 400;
color: var(--text-muted);
}
/* ---------- Main layout ---------- */
.shell {
flex: 1;
width: 100%;
max-width: 1040px;
margin: 0 auto;
padding: 40px 24px 64px;
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: 32px;
align-items: start;
}
.main {
min-width: 0;
}
/* ---------- Input view ---------- */
.input-view {
display: flex;
flex-direction: column;
gap: 12px;
}
.input-label {
font-size: 14px;
font-weight: 500;
color: var(--text);
}
.tos-input {
width: 100%;
resize: vertical;
min-height: 260px;
padding: 14px 16px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
font-family: inherit;
font-size: 14px;
line-height: 1.6;
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.tos-input::placeholder {
color: var(--text-faint);
}
.tos-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.input-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
color: var(--text-muted);
}
.btn-link {
background: none;
border: none;
padding: 0;
font-family: inherit;
font-size: 13px;
color: var(--text-muted);
cursor: pointer;
transition: color 150ms ease;
}
.btn-link:hover {
color: var(--text);
}
.hint {
margin: 0;
font-size: 13px;
color: var(--warn-text);
}
/* ---------- Buttons ---------- */
.btn {
font-family: inherit;
font-size: 14px;
font-weight: 500;
border-radius: var(--radius);
padding: 10px 18px;
cursor: pointer;
border: 1px solid transparent;
transition: background 150ms ease, color 150ms ease, border-color 150ms ease,
opacity 150ms ease;
}
.btn-primary {
align-self: center;
min-width: 200px;
margin-top: 8px;
color: #ffffff;
background: var(--accent);
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.btn-primary:disabled {
background: var(--border);
color: var(--text-faint);
cursor: not-allowed;
}
.btn-secondary {
background: var(--bg);
border-color: var(--border);
color: var(--text);
}
.btn-secondary:hover {
background: var(--surface);
}
.btn-ghost {
background: transparent;
border-color: var(--border);
color: var(--text-muted);
padding: 7px 14px;
font-size: 13px;
}
.btn-ghost:hover {
background: var(--surface);
color: var(--text);
}
/* ---------- Loading ---------- */
.loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 96px 24px;
text-align: center;
}
.loading p {
margin: 0;
font-size: 15px;
font-weight: 500;
color: var(--text);
}
.loading small {
font-size: 13px;
color: var(--text-muted);
}
.spinner {
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid var(--border);
border-top-color: var(--accent);
animation: spin 700ms linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ---------- Dashboard ---------- */
.dashboard-head {
margin-bottom: 24px;
}
.dashboard {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.card {
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
background: var(--surface);
}
.card h2 {
margin: 0 0 16px;
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
}
.score-card {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.score-grade {
font-size: 72px;
line-height: 1;
font-weight: 600;
letter-spacing: -0.04em;
color: var(--text);
}
.grade-good .score-grade {
color: #059669;
}
.grade-warn .score-grade {
color: #d97706;
}
.grade-bad .score-grade {
color: #dc2626;
}
.score-caption {
margin: 10px 0 0;
font-size: 13px;
font-weight: 400;
color: var(--text-muted);
}
.summary-card p {
margin: 0;
font-size: 15px;
line-height: 1.65;
}
.takeaways-card,
.flags-card {
grid-column: span 2;
}
.takeaway-list,
.flag-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 12px;
}
.takeaway-list li {
display: flex;
align-items: flex-start;
gap: 10px;
font-size: 14px;
line-height: 1.6;
}
.takeaway-icon {
flex-shrink: 0;
}
/* ---------- Red flags ---------- */
.flags-clear .all-clear {
color: var(--good-text);
}
.all-clear {
margin: 0;
font-size: 14px;
font-weight: 500;
}
.flag {
border-radius: var(--radius);
padding: 12px 16px;
border: 1px solid transparent;
}
.flag-low {
background: var(--good-bg);
}
.flag-medium {
background: var(--warn-bg);
}
.flag-high {
background: var(--bad-bg);
}
.flag-header {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
margin-bottom: 4px;
}
.flag strong {
font-size: 14px;
font-weight: 600;
}
.flag-low .flag strong {
color: var(--good-text);
}
.flag-medium .flag strong {
color: var(--warn-text);
}
.flag-high .flag strong {
color: var(--bad-text);
}
.flag p {
margin: 0;
font-size: 13px;
line-height: 1.55;
color: var(--text-muted);
}
.severity-pill {
flex-shrink: 0;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 2px 8px;
border-radius: 999px;
}
.flag-low .severity-pill {
background: rgba(5, 150, 105, 0.12);
color: var(--good-text);
}
.flag-medium .severity-pill {
background: rgba(217, 119, 6, 0.12);
color: var(--warn-text);
}
.flag-high .severity-pill {
background: rgba(220, 38, 38, 0.1);
color: var(--bad-text);
}
/* ---------- Error state ---------- */
.error-card {
max-width: 480px;
margin: 48px auto 0;
text-align: center;
}
.error-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--bad-bg);
color: var(--bad-text);
margin-bottom: 16px;
}
.error-card h2 {
color: var(--text);
}
.error-card p {
margin: 0 0 20px;
font-size: 14px;
color: var(--text-muted);
}
/* ---------- Sidebar / cached scans ---------- */
.sidebar {
position: sticky;
top: 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
.sidebar-head {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.sidebar-head h2 {
margin: 0;
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
}
.sidebar-empty {
margin: 0;
font-size: 13px;
line-height: 1.6;
color: var(--text-faint);
}
.history-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
}
.history-list li {
position: relative;
}
.history-item {
width: 100%;
display: flex;
align-items: flex-start;
gap: 12px;
text-align: left;
padding: 10px 28px 10px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
transition: background 150ms ease, border-color 150ms ease;
}
.history-item:hover {
background: var(--bg);
border-color: var(--text-faint);
}
.history-grade {
font-size: 18px;
font-weight: 600;
line-height: 1.2;
color: var(--text);
min-width: 16px;
}
.grade-good .history-grade {
color: #059669;
}
.grade-warn .history-grade {
color: #d97706;
}
.grade-bad .history-grade {
color: #dc2626;
}
.history-body {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.history-title {
font-size: 13px;
font-weight: 500;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.history-date {
font-size: 11px;
color: var(--text-faint);
}
.history-remove {
position: absolute;
top: 6px;
right: 6px;
width: 20px;
height: 20px;
display: grid;
place-items: center;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: var(--text-faint);
font-size: 14px;
line-height: 1;
cursor: pointer;
opacity: 0;
transition: opacity 150ms ease, color 150ms ease, background 150ms ease;
}
.history-list li:hover .history-remove,
.history-remove:focus-visible {
opacity: 1;
}
.history-remove:hover {
color: var(--bad-text);
background: var(--bad-bg);
}
li.active > .history-item {
border-color: var(--accent);
}
.sidebar .btn-link {
font-size: 12px;
}
/* ---------- Footer ---------- */
.footer {
max-width: 720px;
margin: 0 auto;
width: 100%;
padding: 20px 24px 32px;
font-size: 13px;
color: var(--text-faint);
}
/* ---------- Responsive ---------- */
@media (max-width: 900px) {
.shell {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
order: -1;
}
}
@media (max-width: 640px) {
.header,
.shell,
.footer {
padding-left: 20px;
padding-right: 20px;
}
.dashboard {
grid-template-columns: 1fr;
}
.takeaways-card,
.flags-card {
grid-column: auto;
}
}
+286
View File
@@ -0,0 +1,286 @@
import { useEffect, useState } from 'react'
import { scanAgreement } from './lib/gemini'
import { loadHistory, saveHistory, makeTitle } from './lib/storage'
import './App.css'
const GRADE_STYLES = {
A: 'grade-good',
B: 'grade-good',
C: 'grade-warn',
D: 'grade-bad',
F: 'grade-bad',
}
const SEVERITY_LABELS = {
low: 'Low severity',
medium: 'Medium severity',
high: 'High severity',
}
const SAMPLE_TEXT = `By creating an account, you grant the Company a perpetual, irrevocable, worldwide license to use, reproduce, and share any content you upload with third-party marketing partners. Subscriptions renew automatically at full price unless cancelled 30 days in advance. Disputes must be resolved through binding arbitration; you waive your right to a jury trial and class action. The Company may modify these terms at any time without notice. We collect device identifiers, location data, and browsing history to personalize advertising. You may request account deletion via postal mail only.`
function formatDate(ts) {
return new Date(ts).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
}
function App() {
const [text, setText] = useState('')
const [status, setStatus] = useState('idle') // idle | loading | done | error
const [result, setResult] = useState(null)
const [error, setError] = useState(null)
const [history, setHistory] = useState(loadHistory)
const [activeId, setActiveId] = useState(null)
useEffect(() => {
saveHistory(history)
}, [history])
async function handleScan(e) {
e.preventDefault()
if (text.trim().length < 100) return
setStatus('loading')
setError(null)
setResult(null)
try {
const trimmed = text.trim()
const analysis = await scanAgreement(trimmed)
setResult(analysis)
setStatus('done')
const entry = {
id: `${Date.now()}`,
title: makeTitle(trimmed),
createdAt: Date.now(),
result: analysis,
}
setHistory((prev) => [entry, ...prev])
setActiveId(entry.id)
} catch (err) {
setError(err.message || 'Something went wrong.')
setStatus('error')
}
}
function handleReset() {
setStatus('idle')
setResult(null)
setError(null)
setText('')
setActiveId(null)
}
function handleSelect(entry) {
setResult(entry.result)
setStatus('done')
setError(null)
setActiveId(entry.id)
window.scrollTo({ top: 0 })
}
function handleDelete(id) {
setHistory((prev) => prev.filter((item) => item.id !== id))
if (activeId === id) setActiveId(null)
}
function handleClearAll() {
setHistory([])
setActiveId(null)
}
return (
<div className="app">
<header className="header">
<h1 className="logo">ToS Scanner</h1>
<p className="tagline">Read the fine print before you click.</p>
</header>
<div className="shell">
<main className="main">
{status === 'error' && (
<section className="card error-card" role="alert">
<span className="error-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
</span>
<h2>Scan failed</h2>
<p>{error}</p>
<button type="button" onClick={handleReset} className="btn btn-secondary">
Try again
</button>
</section>
)}
{status === 'loading' && (
<section className="loading" aria-live="polite" aria-busy="true">
<span className="spinner" />
<p>Analyzing agreement&hellip;</p>
<small>Reading every clause so you don&apos;t have to.</small>
</section>
)}
{status === 'done' && result && (
<>
<section className="dashboard-head">
<button
type="button"
onClick={handleReset}
className="btn btn-ghost"
>
&larr; Scan another
</button>
</section>
<div className="dashboard">
<section className={`card score-card ${GRADE_STYLES[result.safety_score] ?? ''}`}>
<h2>Safety Score</h2>
<div className="score-grade">{result.safety_score}</div>
<p className="score-caption">
{result.safety_score === 'A' && 'Consumer-friendly terms'}
{result.safety_score === 'B' && 'Mostly fair terms'}
{result.safety_score === 'C' && 'Some concerning clauses'}
{result.safety_score === 'D' && 'Unfair terms present'}
{result.safety_score === 'F' && 'Predatory terms'}
{result.safety_score === '?' && 'Unable to grade'}
</p>
</section>
<section className="card summary-card">
<h2>Summary</h2>
<p>{result.summary}</p>
</section>
<section className="card takeaways-card">
<h2>Key Takeaways</h2>
<ul className="takeaway-list">
{result.key_takeaways.map((item, i) => (
<li key={i} className={`takeaway-${item.type === 'good' ? 'good' : 'bad'}`}>
<span className="takeaway-icon" aria-hidden="true">
{item.type === 'good' ? '👍' : '⚠️'}
</span>
<span>{typeof item === 'string' ? item : item.text}</span>
</li>
))}
</ul>
</section>
<section className={`card flags-card ${result.red_flags.length ? '' : 'flags-clear'}`}>
<h2>Red Flags</h2>
{result.red_flags.length === 0 ? (
<p className="all-clear">No red flags detected. This agreement looks clean.</p>
) : (
<ul className="flag-list">
{result.red_flags.map((flag, i) => (
<li key={i} className={`flag flag-${flag.severity}`}>
<div className="flag-header">
<strong>{flag.title}</strong>
<span className="severity-pill">{SEVERITY_LABELS[flag.severity] ?? flag.severity}</span>
</div>
<p>{flag.detail}</p>
</li>
))}
</ul>
)}
</section>
</div>
</>
)}
{(status === 'idle') && (
<form className="input-view" onSubmit={handleScan}>
<label htmlFor="tos-input" className="input-label">
Paste a Terms of Service or Privacy Policy below.
</label>
<textarea
id="tos-input"
className="tos-input"
placeholder="Paste the full agreement text here&hellip;"
value={text}
onChange={(e) => setText(e.target.value)}
rows={12}
spellCheck="false"
/>
<div className="input-meta">
<span>{text.trim().length.toLocaleString()} characters</span>
<button
type="button"
className="btn-link"
onClick={() => setText(SAMPLE_TEXT)}
>
Use sample text
</button>
</div>
<button
type="submit"
className="btn btn-primary"
disabled={text.trim().length < 100}
>
Scan Agreement
</button>
{text.trim().length > 0 && text.trim().length < 100 && (
<p className="hint">Paste at least 100 characters for an accurate scan.</p>
)}
</form>
)}
</main>
<aside className="sidebar">
<div className="sidebar-head">
<h2>Cached Scans</h2>
{history.length > 0 && (
<button type="button" className="btn-link" onClick={handleClearAll}>
Clear all
</button>
)}
</div>
{history.length === 0 ? (
<p className="sidebar-empty">No cached scans yet. Your past results will appear here.</p>
) : (
<ul className="history-list">
{history.map((entry) => (
<li key={entry.id} className={activeId === entry.id ? 'active' : ''}>
<button
type="button"
className={`history-item ${GRADE_STYLES[entry.result?.safety_score] ?? ''}`}
onClick={() => handleSelect(entry)}
title={entry.title}
>
<span className="history-grade">{entry.result?.safety_score ?? '?'}</span>
<span className="history-body">
<span className="history-title">{entry.title}</span>
<span className="history-date">{formatDate(entry.createdAt)}</span>
</span>
</button>
<button
type="button"
className="history-remove"
aria-label="Remove cached scan"
onClick={() => handleDelete(entry.id)}
>
&times;
</button>
</li>
))}
</ul>
)}
</aside>
</div>
<footer className="footer">
Not legal advice. Always read agreements yourself when it matters.
</footer>
</div>
)
}
export default App
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+49
View File
@@ -0,0 +1,49 @@
:root {
/* Neutrals */
--bg: #ffffff;
--surface: #fafafa;
--border: #e5e5e5;
--text: #171717;
--text-muted: #737373;
--text-faint: #a3a3a3;
/* Accent — deep emerald, used only for primary actions & highlights */
--accent: #059669;
--accent-hover: #047857;
--accent-soft: rgba(5, 150, 105, 0.08);
/* Risk tones — desaturated backgrounds with dark text */
--good-bg: #ecfdf5;
--good-text: #065f46;
--warn-bg: #fffbeb;
--warn-text: #92400e;
--bad-bg: #fef2f2;
--bad-text: #991b1b;
--radius: 8px;
font-family:
'Inter',
system-ui,
-apple-system,
'Segoe UI',
Roboto,
sans-serif;
color: var(--text);
background: var(--bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background: var(--bg);
color: var(--text);
}
+139
View File
@@ -0,0 +1,139 @@
const MODEL = 'gemini-3.6-flash'
const API_URL = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`
const SYSTEM_PROMPT = `You are a meticulous legal analyst who reviews Terms of Service and Privacy Policy agreements for everyday consumers.
Analyze the provided agreement text and return a single JSON object with exactly these fields:
1. "safety_score": A letter grade from "A" (consumer-friendly) to "F" (predatory). Base it on how fair, transparent, and balanced the terms are toward the user.
2. "summary": Exactly 2 plain-English sentences summarizing what this agreement means for the user.
3. "key_takeaways": An array with ONE takeaway for EVERY notable point you find in the agreement — do not cap the count. Cover data collection practices, user rights (deletion, portability, cancellation), and financial terms (payments, auto-renewal, fees). Each object has:
- "text": The takeaway in plain English
- "type": "good" if the point is consumer-friendly or favorable to the user, "bad" if it is concerning or unfavorable to the user
4. "red_flags": An array of 0-5 objects describing hidden traps, forced arbitration clauses, class-action waivers, unilateral changes, or aggressive data-sharing. Each object has:
- "title": Short label (e.g. "Forced Arbitration")
- "detail": One sentence explaining the clause in plain English
- "severity": One of "low", "medium", "high"
Rules: Write in plain language a non-lawyer understands. Quote specific clauses only when necessary. If the agreement is genuinely benign, return an empty red_flags array — do not invent problems.
CRITICAL: Your entire response must be ONLY the raw JSON object itself. No markdown fences, no explanations, no text before or after the JSON.`
const RESPONSE_SCHEMA = {
type: 'OBJECT',
properties: {
safety_score: {
type: 'STRING',
description: 'Letter grade from A to F',
enum: ['A', 'B', 'C', 'D', 'F'],
},
summary: { type: 'STRING' },
key_takeaways: {
type: 'ARRAY',
items: {
type: 'OBJECT',
properties: {
text: { type: 'STRING' },
type: { type: 'STRING', enum: ['good', 'bad'] },
},
required: ['text', 'type'],
},
},
red_flags: {
type: 'ARRAY',
items: {
type: 'OBJECT',
properties: {
title: { type: 'STRING' },
detail: { type: 'STRING' },
severity: { type: 'STRING', enum: ['low', 'medium', 'high'] },
},
required: ['title', 'detail', 'severity'],
},
},
},
required: ['safety_score', 'summary', 'key_takeaways', 'red_flags'],
}
export async function scanAgreement(text) {
const apiKey = import.meta.env.VITE_GEMINI_API_KEY
if (!apiKey) {
throw new Error(
'Gemini API key is missing. Add VITE_GEMINI_API_KEY to your .env file and restart the dev server.',
)
}
let response
try {
response = await fetch(API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': apiKey,
},
body: JSON.stringify({
systemInstruction: { parts: [{ text: SYSTEM_PROMPT }] },
contents: [
{
role: 'user',
parts: [{ text: `Analyze this agreement:\n\n${text}` }],
},
],
generationConfig: {
temperature: 0.2,
responseMimeType: 'application/json',
responseSchema: RESPONSE_SCHEMA,
},
}),
})
} catch {
throw new Error('Network error. Check your connection and try again.')
}
if (!response.ok) {
let detail = ''
try {
const errBody = await response.json()
detail = errBody?.error?.message ? `: ${errBody.error.message}` : ''
} catch {
// keep empty detail
}
if (response.status === 400 || response.status === 403) {
throw new Error(`Invalid or unauthorized Gemini API key${detail}`)
}
if (response.status === 429) {
throw new Error('Rate limit reached. Wait a moment and try again.')
}
throw new Error(`Gemini API error (${response.status})${detail}. Try again later.`)
}
const data = await response.json()
const raw = data?.candidates?.[0]?.content?.parts?.[0]?.text
if (!raw) {
throw new Error('Gemini returned an empty response. Try again.')
}
try {
const result = JSON.parse(raw)
return {
safety_score: result.safety_score ?? '?',
summary: result.summary ?? '',
key_takeaways: Array.isArray(result.key_takeaways)
? result.key_takeaways.map((t) =>
typeof t === 'string'
? { text: t, type: 'bad' }
: {
text: t.text ?? '',
type: t.type === 'good' ? 'good' : 'bad',
},
)
: [],
red_flags: Array.isArray(result.red_flags) ? result.red_flags : [],
}
} catch {
throw new Error('Could not parse the analysis. Please try again.')
}
}
+26
View File
@@ -0,0 +1,26 @@
const STORAGE_KEY = 'tos-scanner-history'
const MAX_ENTRIES = 15
export function loadHistory() {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
export function saveHistory(history) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(history.slice(0, MAX_ENTRIES)))
} catch {
// Storage unavailable or full — caching is best-effort
}
}
export function makeTitle(text) {
const line = text.trim().split('\n')[0].trim()
return line.length > 64 ? `${line.slice(0, 64).trimEnd()}…` : line
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})