rampart / index.html
taigrr's picture
Remove Apt 5B from EN example and placeholder
8745bf7 verified
Raw
History Blame
50.5 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0">
<title>Rampart — Client-Side PII Redaction</title>
<meta name="description" content="14.7 MB ONNX model that detects and redacts PII in your browser before it leaves your device. No server, no API, no data sent anywhere.">
<style>
:root {
--bg: #000000;
--bg-elev: #0c0c0c;
--bg-card: #070707;
--border: rgba(255,255,255,0.14);
--border-bright: rgba(255,255,255,0.30);
--text: #ffffff;
--text-dim: rgba(255,255,255,0.62);
--text-dimmer: rgba(255,255,255,0.40);
--accent: #ffffff;
--accent-dim: #ffffff;
--accent-glow: rgba(255,255,255,0.08);
--green: #5dd39e;
--red: #ff5d5d;
--radius: 2px;
--font: "PP Neue Montreal", "Neue Montreal", "Helvetica Neue", Helvetica, Arial, sans-serif;
--mono: "SF Mono", "Fira Code", "Fira Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
line-height: 1.6;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
overflow-x: hidden;
}
::selection { background: var(--accent-dim); color: white; }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border-bright); border-radius: 3px; }
/* ===== HERO ===== */
.hero {
position: relative;
padding: 64px 24px 48px;
text-align: center;
overflow: hidden;
}
.hero::before {
content: "";
position: absolute;
top: -40%;
left: 50%;
transform: translateX(-50%);
width: 700px;
height: 700px;
background: radial-gradient(circle, rgba(124, 58, 237, 0.12) 0%, transparent 60%);
pointer-events: none;
}
.hero-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
border: 1px solid var(--border-bright);
border-radius: 99px;
font-size: 13px;
color: var(--text-dim);
background: var(--bg-elev);
margin-bottom: 28px;
position: relative;
}
.hero-badge .dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--green);
box-shadow: 0 0 8px var(--green);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.hero h1 {
font-size: clamp(2rem, 6vw, 3.5rem);
font-weight: 800;
letter-spacing: -0.03em;
margin-bottom: 16px;
position: relative;
background: linear-gradient(135deg, #fff 0%, #a78bfa 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero p {
font-size: clamp(1rem, 2vw, 1.15rem);
color: var(--text-dim);
max-width: 560px;
margin: 0 auto;
position: relative;
}
.hero-stats {
display: flex;
justify-content: center;
gap: 32px;
margin-top: 36px;
flex-wrap: wrap;
position: relative;
}
.stat { text-align: center; }
.stat-val {
font-size: 1.5rem;
font-weight: 700;
color: var(--text);
font-variant-numeric: tabular-nums;
}
.stat-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dimmer);
}
/* ===== LAYOUT ===== */
.container {
max-width: 1100px;
margin: 0 auto;
padding: 0 24px 80px;
}
.section { margin-bottom: 48px; }
.section-title {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-dimmer);
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 10px;
}
.section-title::after {
content: "";
flex: 1;
height: 1px;
background: var(--border);
}
/* ===== DEMO PANEL ===== */
.demo-panel {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.demo-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
.demo-header-left { display: flex; align-items: center; gap: 10px; }
.demo-header-title { font-size: 14px; font-weight: 600; }
.demo-header-title .icon { margin-right: 6px; }
.model-status {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-dim);
padding: 4px 10px;
border-radius: 6px;
background: var(--bg-elev);
border: 1px solid var(--border);
}
.model-status .dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-dimmer);
}
.model-status.loading .dot { background: #fbbf24; animation: pulse 1s infinite; }
.model-status.ready .dot { background: var(--green); box-shadow: 0 0 6px var(--green); }
.model-status.error .dot { background: var(--red); }
/* ===== INPUT AREA ===== */
.input-area { padding: 20px; }
textarea.input-box {
width: 100%;
min-height: 140px;
background: var(--bg);
border: 1px solid var(--border-bright);
border-radius: 8px;
color: var(--text);
font-family: var(--font);
font-size: 15px;
line-height: 1.7;
padding: 16px;
resize: vertical;
outline: none;
transition: border-color 0.2s;
}
textarea.input-box:focus { border-color: var(--accent-dim); }
textarea.input-box::placeholder { color: var(--text-dimmer); }
/* ===== HIGHLIGHTED OUTPUT ===== */
.output-area {
padding: 0 20px 20px;
}
.output-label {
font-size: 12px;
color: var(--text-dim);
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 8px;
}
.output-box {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
font-size: 15px;
line-height: 1.8;
min-height: 80px;
white-space: pre-wrap;
word-break: break-word;
}
.output-box.empty { color: var(--text-dimmer); font-style: italic; }
.entity-highlight {
padding: 2px 6px;
border-radius: 4px;
font-weight: 600;
font-size: 0.9em;
cursor: default;
position: relative;
transition: filter 0.15s;
}
.entity-highlight:hover { filter: brightness(1.3); }
/* ===== ACTION BAR ===== */
.action-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 20px;
border-top: 1px solid var(--border);
flex-wrap: wrap;
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border-radius: 6px;
border: 1px solid var(--border-bright);
background: var(--bg-elev);
color: var(--text-dim);
font-size: 13px;
font-family: var(--font);
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.btn:hover { border-color: var(--accent-dim); color: var(--text); }
.btn:active { transform: scale(0.97); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-primary {
background: var(--accent-dim);
border-color: var(--accent-dim);
color: white;
}
.btn-primary:hover { background: #6d28d9; border-color: #6d28d9; color: white; }
/* ===== EXAMPLES ===== */
.examples-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 10px;
}
.example-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 16px;
cursor: pointer;
transition: all 0.15s;
}
.example-card:hover { border-color: var(--accent-dim); background: var(--bg-elev); }
.example-card-title {
font-size: 12px;
font-weight: 600;
color: var(--accent);
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 6px;
}
.example-card-body {
font-size: 13px;
color: var(--text-dim);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* ===== ENTITY LEGEND ===== */
.legend-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 8px;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
transition: border-color 0.15s;
}
.legend-item:hover { border-color: var(--border-bright); }
.legend-swatch {
width: 12px;
height: 12px;
border-radius: 3px;
flex-shrink: 0;
}
.legend-label { font-weight: 600; color: var(--text); }
.legend-desc { color: var(--text-dim); font-size: 12px; }
/* ===== STATS ===== */
.stats-bar {
display: flex;
gap: 20px;
flex-wrap: wrap;
padding: 12px 20px;
border-top: 1px solid var(--border);
font-size: 13px;
}
.stat-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 6px;
background: var(--bg-elev);
border: 1px solid var(--border);
color: var(--text-dim);
}
.stat-pill .count { font-weight: 700; color: var(--text); }
/* ===== CHAT SIM ===== */
.chat-panel {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.chat-messages {
padding: 20px;
min-height: 200px;
max-height: 400px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.chat-msg {
max-width: 80%;
padding: 10px 14px;
border-radius: 10px;
font-size: 14px;
line-height: 1.6;
}
.chat-msg.user {
align-self: flex-end;
background: var(--accent-dim);
color: white;
}
.chat-msg.user-redacted {
align-self: flex-end;
background: var(--bg-elev);
border: 1px solid var(--border-bright);
color: var(--text-dim);
font-size: 13px;
}
.chat-msg.assistant {
align-self: flex-start;
background: var(--bg-elev);
border: 1px solid var(--border);
}
.chat-input-row {
display: flex;
gap: 10px;
padding: 16px 20px;
border-top: 1px solid var(--border);
}
.chat-input {
flex: 1;
background: var(--bg);
border: 1px solid var(--border-bright);
border-radius: 8px;
color: var(--text);
font-family: var(--font);
font-size: 14px;
padding: 10px 14px;
outline: none;
}
.chat-input:focus { border-color: var(--accent-dim); }
/* ===== COPY BUTTON ===== */
.copy-btn {
position: relative;
}
.copy-btn.copied::after {
content: "✓";
position: absolute;
color: var(--green);
margin-left: 4px;
}
/* ===== LOADING OVERLAY ===== */
.loading-overlay {
position: fixed;
inset: 0;
background: rgba(10, 10, 15, 0.85);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
transition: opacity 0.3s;
}
.loading-overlay.hidden { opacity: 0; pointer-events: none; }
.loading-content { text-align: center; }
.loading-spinner {
width: 48px;
height: 48px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading-text { color: var(--text); font-size: 15px; font-weight: 600; }
.loading-sub { color: var(--text-dim); font-size: 13px; margin-top: 8px; }
.loading-progress {
width: 200px;
height: 3px;
background: var(--border);
border-radius: 2px;
margin: 16px auto 0;
overflow: hidden;
}
.loading-progress-bar {
height: 100%;
background: var(--accent);
border-radius: 2px;
width: 0%;
transition: width 0.3s;
}
/* ===== FOOTER ===== */
.footer {
text-align: center;
padding: 40px 24px;
color: var(--text-dimmer);
font-size: 13px;
border-top: 1px solid var(--border);
}
.footer a { color: var(--text-dim); text-decoration: none; }
.footer a:hover { color: var(--accent); }
/* ===== RESPONSIVE ===== */
@media (max-width: 640px) {
.hero { padding: 40px 16px 32px; }
.container { padding: 0 16px 60px; }
.hero-stats { gap: 20px; }
.examples-grid { grid-template-columns: 1fr; }
}
/* ===== TABS ===== */
.tabs {
display: flex;
gap: 4px;
margin-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.tab {
padding: 10px 18px;
font-size: 14px;
font-weight: 600;
color: var(--text-dim);
cursor: pointer;
border: none;
background: none;
border-bottom: 2px solid transparent;
transition: all 0.15s;
font-family: var(--font);
}
.tab:hover { color: var(--text); }
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.tab-content { display: none; }
.tab-content.active { display: block; }
/* ===== PLACEHOLDER MAPPING ===== */
.mapping-list {
display: flex;
flex-direction: column;
gap: 6px;
padding: 16px 20px;
}
.mapping-row {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
font-family: var(--mono);
}
.mapping-placeholder {
color: var(--accent);
font-weight: 600;
min-width: 180px;
}
.mapping-arrow { color: var(--text-dimmer); }
.mapping-value {
color: var(--text);
background: var(--bg);
padding: 2px 8px;
border-radius: 4px;
border: 1px solid var(--border);
}
.mapping-empty {
color: var(--text-dimmer);
font-style: italic;
font-family: var(--font);
}
/* ===== TOOLTIP ===== */
.tooltip {
position: absolute;
background: var(--bg-elev);
border: 1px solid var(--border-bright);
border-radius: 6px;
padding: 6px 10px;
font-size: 12px;
color: var(--text);
pointer-events: none;
z-index: 50;
white-space: nowrap;
opacity: 0;
transition: opacity 0.15s;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
.tooltip.visible { opacity: 1; }
.tooltip .tt-label { font-weight: 700; }
.tooltip .tt-original { color: var(--text-dim); margin-top: 2px; }
.tooltip .tt-score { color: var(--text-dimmer); margin-top: 2px; }
/* ===================================================================== */
/* National Design Studio editorial theme — black/white, Swiss grid, */
/* hairline rules, Roman-numeral section markers, arrow affordances. */
/* ===================================================================== */
body { line-height: 1.5; letter-spacing: -0.005em; }
::selection { background: #fff; color: #000; }
/* Top nav */
.nav {
display: flex; align-items: baseline; justify-content: space-between;
gap: 16px; padding: 22px 40px; border-bottom: 1px solid var(--border);
}
.nav-brand { font-size: 15px; font-weight: 500; letter-spacing: -0.01em; }
.nav-links { display: flex; gap: 26px; font-size: 14px; }
.nav-links a { color: var(--text-dim); text-decoration: none; transition: color .15s; }
.nav-links a:hover { color: #fff; }
.nav-links a .arr { display: inline-block; transition: transform .2s ease; }
.nav-links a:hover .arr { transform: translate(2px,-2px); }
/* Hero — left-aligned editorial */
.hero { text-align: left; padding: 80px 40px 56px; overflow: visible; }
.hero::before { display: none; }
.hero-badge {
border: 1px solid var(--border); border-radius: 99px; background: transparent;
color: var(--text-dim); margin-bottom: 36px; text-transform: none; letter-spacing: 0;
}
.hero-badge .dot { background: var(--green); box-shadow: none; }
.hero h1 {
font-size: clamp(3rem, 9vw, 6.5rem); font-weight: 500; letter-spacing: -0.04em;
line-height: 0.95; margin-bottom: 22px;
background: none; -webkit-text-fill-color: #fff; color: #fff;
}
.hero p {
font-size: clamp(1.15rem, 2.2vw, 1.6rem); color: var(--text); line-height: 1.12;
max-width: 760px; margin: 0; letter-spacing: -0.02em; font-weight: 400;
}
.hero p .muted { color: var(--text-dim); }
.hero-stats {
justify-content: flex-start; gap: 0; margin-top: 56px;
border-top: 1px solid var(--border);
}
.stat {
text-align: left; flex: 1; min-width: 120px; padding: 18px 24px 0 0;
}
.stat-val { font-size: 1.9rem; font-weight: 500; letter-spacing: -0.03em; }
.stat-label {
text-transform: uppercase; letter-spacing: 0.12em; font-size: 0.66rem;
color: var(--text-dimmer); margin-top: 4px;
}
/* Layout */
.container { max-width: 1280px; padding: 0 40px 96px; }
.section { margin-bottom: 72px; }
/* Section header: Roman numeral + label, hairline above */
.section-title {
text-transform: none; letter-spacing: -0.01em; font-size: 1.05rem; color: var(--text);
border-top: 1px solid var(--border); padding-top: 20px; margin-bottom: 28px;
gap: 22px;
}
.section-title::after { display: none; }
.section-title .rn {
display: inline-block; width: 2.4em; color: var(--text-dimmer);
font-variant-numeric: normal; font-feature-settings: normal;
font-size: 0.85rem; letter-spacing: 0.04em;
}
.section-title .lede { color: var(--text-dim); font-size: 0.95rem; margin-left: auto; max-width: 46ch; text-align: right; }
/* Panels — square, hairline */
.demo-panel, .chat-panel { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); }
.demo-header { padding: 18px 22px; }
.demo-header-title { font-weight: 400; color: var(--text-dim); font-size: 13px; }
.model-status { border-radius: 99px; background: transparent; }
textarea.input-box, .chat-input, .output-box, .mapping-value, .stat-pill, .legend-item, .example-card {
border-radius: var(--radius);
}
textarea.input-box, .chat-input { background: #000; }
textarea.input-box:focus, .chat-input:focus { border-color: #fff; }
.output-box { background: #000; }
/* Buttons — square, uppercase micro-label, invert on hover */
.btn {
border-radius: var(--radius); background: transparent; color: var(--text-dim);
text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 500;
border: 1px solid var(--border); padding: 8px 14px;
}
.btn:hover { border-color: #fff; color: #fff; background: transparent; }
.btn-primary { background: #fff; border-color: #fff; color: #000; }
.btn-primary:hover { background: var(--text-dim); border-color: var(--text-dim); color: #000; }
/* Tabs — minimal text, underline active */
.tabs { gap: 28px; }
.tab {
text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 500;
padding: 12px 0; color: var(--text-dimmer);
}
.tab:hover { color: var(--text); }
.tab.active { color: #fff; border-bottom-color: #fff; }
/* Cards */
.example-card { background: var(--bg-card); }
.example-card:hover { border-color: #fff; background: var(--bg-elev); }
.example-card-title { color: #fff; text-transform: none; letter-spacing: 0; }
.legend-item:hover { border-color: var(--border-bright); }
.mapping-placeholder { color: #fff; }
/* Footer — inverted white block, ND Studio masthead */
.footer {
background: #fff; color: #000; text-align: left; border-top: none;
padding: 56px 40px; display: grid; gap: 32px;
grid-template-columns: 1.2fr 1fr; align-items: start; font-size: 14px;
}
.footer a { color: #000; text-decoration: underline; text-underline-offset: 3px; }
.footer a:hover { color: #000; opacity: 0.6; }
.footer .f-mark { font-size: 1.5rem; font-weight: 500; letter-spacing: -0.03em; line-height: 1.05; max-width: 18ch; }
.footer .f-addr { color: #333; line-height: 1.5; margin-top: 16px; font-size: 13px; }
.footer .f-links { display: flex; flex-wrap: wrap; gap: 18px 26px; align-content: start; }
.footer .f-legal { grid-column: 1 / -1; border-top: 1px solid rgba(0,0,0,0.15); padding-top: 20px; color: #444; font-size: 12px; display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; }
.loading-overlay { background: rgba(0,0,0,0.92); }
.loading-spinner { border-color: var(--border); border-top-color: #fff; }
.loading-progress-bar { background: #fff; }
@media (max-width: 768px) {
.nav, .hero, .container, .footer { padding-left: 20px; padding-right: 20px; }
.hero { padding-top: 48px; }
.nav-links { gap: 16px; }
.section-title { flex-wrap: wrap; }
.section-title .lede { margin-left: 0; text-align: left; max-width: none; }
.footer { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<!-- LOADING OVERLAY -->
<div class="loading-overlay" id="loadingOverlay">
<div class="loading-content">
<div class="loading-spinner"></div>
<div class="loading-text" id="loadingText">Loading Rampart model…</div>
<div class="loading-sub" id="loadingSub">14.7 MB · ONNX Runtime Web · WASM</div>
<div class="loading-progress"><div class="loading-progress-bar" id="loadingBar"></div></div>
</div>
</div>
<!-- NAV -->
<nav class="nav">
<div class="nav-brand">National Design Studio</div>
<div class="nav-links">
<a href="https://huggingface.co/nationaldesignstudio/rampart" target="_blank" rel="noopener">Model <span class="arr"></span></a>
<a href="https://ndstudio.gov" target="_blank" rel="noopener">ndstudio.gov <span class="arr"></span></a>
</div>
</nav>
<!-- HERO -->
<div class="hero">
<div class="hero-badge">
<span class="dot"></span>
Runs entirely in your browser — no data leaves your device
</div>
<h1>Rampart</h1>
<p>Personal information, redacted before it ever leaves your device. <span class="muted">A 14.7&nbsp;MB on-device model that finds and removes PII in real time — no server, no API, no data sent anywhere.</span></p>
<div class="hero-stats">
<div class="stat">
<div class="stat-val">14.7<span style="font-size:1rem"> MB</span></div>
<div class="stat-label">Model Size</div>
</div>
<div class="stat">
<div class="stat-val">17</div>
<div class="stat-label">Entity Types</div>
</div>
<div class="stat">
<div class="stat-val">7</div>
<div class="stat-label">Languages</div>
</div>
<div class="stat">
<div class="stat-val">98.4<span style="font-size:1rem">%</span></div>
<div class="stat-label">Private-term recall</div>
</div>
<div class="stat">
<div class="stat-val">~4<span style="font-size:1rem"> ms</span></div>
<div class="stat-label">p50 Latency</div>
</div>
</div>
</div>
<div class="container">
<!-- LIVE REDACTION DEMO -->
<div class="section">
<div class="section-title"><span class="rn">I</span><span>Live Redaction</span><span class="lede">Type or paste text. PII is detected and replaced with stable placeholders, on-device, as you type.</span></div>
<div class="demo-panel">
<div class="demo-header">
<div class="demo-header-left">
<span class="demo-header-title">Type or paste text with PII — watch it get redacted in real time</span>
</div>
<div class="model-status" id="modelStatus">
<span class="dot"></span>
<span id="modelStatusText">Initializing…</span>
</div>
</div>
<div class="input-area">
<textarea class="input-box" id="inputBox" placeholder="Try typing: My name is Sarah Johnson, my email is sarah.johnson@gmail.com, phone 212-867-5309, and my SSN is 472-81-0094. I live at 1234 Maple Street, Springfield, IL 62704." spellcheck="false"></textarea>
</div>
<div class="output-area">
<div class="output-label">
<span>🔒 Redacted output</span>
<button class="btn copy-btn" id="copyBtn" onclick="copyRedacted()">Copy</button>
</div>
<div class="output-box empty" id="outputBox">Start typing above to see PII get redacted…</div>
</div>
<div class="stats-bar" id="statsBar" style="display:none">
<span class="stat-pill"><span class="count" id="entityCount">0</span> entities detected</span>
<span class="stat-pill"><span class="count" id="redactCount">0</span> redacted</span>
<span class="stat-pill"><span class="count" id="retainCount">0</span> retained</span>
<span class="stat-pill">~<span class="count" id="latency">0</span>ms</span>
</div>
<div class="action-bar">
<button class="btn" onclick="loadExample('en1')">📋 English: Personal Info</button>
<button class="btn" onclick="loadExample('es1')">🇪🇸 Spanish</button>
<button class="btn" onclick="loadExample('fr1')">🇫🇷 French</button>
<button class="btn" onclick="loadExample('de1')">🇩🇪 German</button>
<button class="btn" onclick="loadExample('mixed1')">🌍 Mixed Languages</button>
<button class="btn" onclick="loadExample('finance1')">💳 Finance</button>
<button class="btn" onclick="loadExample('medical1')">🏥 Medical Intake</button>
<button class="btn" onclick="clearInput()">✕ Clear</button>
</div>
</div>
</div>
<!-- TABS: Examples / Entity Types / Chat Sim / Rehydration -->
<div class="section">
<div class="section-title"><span class="rn">II</span><span>Reference</span><span class="lede">Examples, the full entity schema, a chat simulation, and the reversible placeholder map.</span></div>
<div class="tabs">
<button class="tab active" onclick="switchTab(event,'examples')">Example Gallery</button>
<button class="tab" onclick="switchTab(event,'entities')">Entity Types</button>
<button class="tab" onclick="switchTab(event,'chat')">Chat Simulation</button>
<button class="tab" onclick="switchTab(event,'mapping')">Placeholder Map</button>
</div>
<!-- EXAMPLES TAB -->
<div class="tab-content active" id="tab-examples">
<div class="examples-grid" id="examplesGrid"></div>
</div>
<!-- ENTITY TYPES TAB -->
<div class="tab-content" id="tab-entities">
<div style="margin-bottom:16px;font-size:14px;color:var(--text-dim)">
Rampart detects 17 entity types using a 35-label BIO schema. A deterministic recognizer layer
handles structured identifiers (SSN, credit cards, emails, URLs, IPs); the neural model handles
contextual PII (names, phones, addresses, document numbers). City, state, and ZIP are
<strong style="color:var(--green)">retained by default</strong> for context.
</div>
<div style="margin-bottom:16px;padding:12px 14px;border:1px solid var(--accent-dim);border-radius:8px;background:var(--bg-elev);font-size:13px;color:var(--text-dim);line-height:1.6">
<strong style="color:var(--accent)">⚠️ Known limitations of these demo inputs</strong>
<ul style="margin:8px 0 0;padding-left:18px">
<li><strong style="color:var(--text)">Phone numbers must not start with <code style="font-family:var(--mono)">555</code>.</strong> The <code style="font-family:var(--mono)">555-01XX</code> exchange is reserved for fiction and is effectively absent from real-world training data, so the model does not recognize it as a phone number. Use a realistic exchange (e.g. <code style="font-family:var(--mono)">212-867-5309</code>) to see PHONE detection fire.</li>
<li><strong style="color:var(--text)">Credit cards that fail the Luhn checksum pass through unredacted.</strong> CREDIT_CARD is caught by the deterministic layer, which validates the Luhn check digit. A made-up number that does not satisfy Luhn is treated as ordinary digits and is <em>not</em> redacted. Use a Luhn-valid test number (e.g. <code style="font-family:var(--mono)">4532 1234 5678 9014</code>) to see it caught.</li>
</ul>
</div>
<div class="legend-grid" id="legendGrid"></div>
</div>
<!-- CHAT SIM TAB -->
<div class="tab-content" id="tab-chat">
<div style="margin-bottom:16px;font-size:14px;color:var(--text-dim)">
See how Rampart protects you before sending text to an AI assistant. Your message is redacted
client-side, the "assistant" sees only placeholders, and the response is rehydrated with your
real values — all in your browser.
</div>
<div class="chat-panel">
<div class="chat-messages" id="chatMessages">
<div class="chat-msg assistant">Hello! I'm a simulated AI assistant. Type a message below — Rampart will redact your PII before it reaches me, and I'll respond using the placeholders.</div>
</div>
<div class="chat-input-row">
<input class="chat-input" id="chatInput" placeholder="Type a message with PII, e.g. 'Hi, I'm John Davis, phone 212-867-5309'" onkeydown="if(event.key==='Enter')sendChat()">
<button class="btn btn-primary" onclick="sendChat()">Send</button>
<button class="btn" onclick="resetChat()">Reset</button>
</div>
</div>
</div>
<!-- MAPPING TAB -->
<div class="tab-content" id="tab-mapping">
<div style="margin-bottom:16px;font-size:14px;color:var(--text-dim)">
Rampart replaces PII with stable, typed placeholders like <code style="color:var(--accent);font-family:var(--mono)">[GIVEN_NAME_1]</code>.
The same value always maps to the same placeholder, so multi-turn conversations stay coherent.
The real values live only in the client-side entity table — they never leave your device.
Type in the demo above, then check here to see the mapping.
</div>
<div class="demo-panel">
<div class="demo-header">
<span class="demo-header-title">Placeholder → Real Value</span>
</div>
<div class="mapping-list" id="mappingList">
<div class="mapping-empty">Type text in the demo above to see placeholder mappings…</div>
</div>
</div>
</div>
</div>
</div>
<!-- FOOTER -->
<footer class="footer">
<div>
<div class="f-mark">National Design Studio</div>
<div class="f-addr">
Eisenhower Executive Office Building<br>
1650 17th St NW,<br>
Washington, DC 20006
</div>
</div>
<div class="f-links">
<a href="https://ndstudio.gov/posts/say-hello-to-rampart" target="_blank" rel="noopener">Read the announcement: Introducing Rampart ↗</a>
</div>
<div class="f-legal">
<span>Rampart model &amp; demo — National Design Studio. CC&nbsp;BY&nbsp;4.0.</span>
<span>This is America, designed.</span>
</div>
</footer>
<!-- TOOLTIP -->
<div class="tooltip" id="tooltip"></div>
<script type="module">
// ===== ENTITY TYPE DEFINITIONS =====
const ENTITY_TYPES = {
// Deterministic layer (regex + validators)
SSN: { color: "#ef4444", desc: "Social Security Number", layer: "Deterministic" },
CREDIT_CARD: { color: "#f97316", desc: "Credit / debit card (Luhn)", layer: "Deterministic" },
EMAIL: { color: "#f59e0b", desc: "Email address", layer: "Deterministic" },
URL: { color: "#eab308", desc: "Web URL", layer: "Deterministic" },
IP_ADDRESS: { color: "#84cc16", desc: "IPv4 / IPv6 / MAC", layer: "Deterministic" },
// Neural model layer
GIVEN_NAME: { color: "#a78bfa", desc: "First / given name", layer: "Neural" },
SURNAME: { color: "#8b5cf6", desc: "Last / family name", layer: "Neural" },
PHONE: { color: "#06b6d4", desc: "Phone number", layer: "Neural" },
TAX_ID: { color: "#0ea5e9", desc: "Tax identifier", layer: "Neural" },
BANK_ACCOUNT: { color: "#3b82f6", desc: "Bank account / IBAN", layer: "Neural" },
ROUTING_NUMBER: { color: "#6366f1", desc: "Bank routing number", layer: "Neural" },
GOVERNMENT_ID: { color: "#8b5cf6", desc: "Gov ID / case number", layer: "Neural" },
PASSPORT: { color: "#a855f7", desc: "Passport number", layer: "Neural" },
DRIVERS_LICENSE:{ color: "#d946ef", desc: "Driver's license", layer: "Neural" },
BUILDING_NUMBER:{ color: "#ec4899", desc: "Street building number", layer: "Neural" },
STREET_NAME: { color: "#f43f5e", desc: "Street name", layer: "Neural" },
SECONDARY_ADDRESS:{ color: "#fb7185", desc: "Apt / unit / suite", layer: "Neural" },
// Retained
CITY: { color: "#4ade80", desc: "City (retained)", layer: "Kept" },
STATE: { color: "#4ade80", desc: "State / region (retained)", layer: "Kept" },
ZIP_CODE: { color: "#4ade80", desc: "Postal code (retained)", layer: "Kept" },
};
const REDACTED_LABELS = new Set([
"SSN","CREDIT_CARD","EMAIL","URL","IP_ADDRESS",
"GIVEN_NAME","SURNAME","PHONE","TAX_ID","BANK_ACCOUNT","ROUTING_NUMBER",
"GOVERNMENT_ID","PASSPORT","DRIVERS_LICENSE","BUILDING_NUMBER","STREET_NAME","SECONDARY_ADDRESS"
]);
const KEPT_LABELS = new Set(["CITY","STATE","ZIP_CODE"]);
// ===== EXAMPLES =====
const EXAMPLES = {
en1: {
title: "🇺🇸 Personal Info (EN)",
body: "Hi, I'm Sarah Johnson. You can reach me at sarah.johnson@gmail.com or 212-867-5309. My SSN is 472-81-0094 and I live at 1234 Maple Street, Springfield, IL 62704."
},
en2: {
title: "🇺🇸 Financial (EN)",
body: "Payment for order #4582: Visa card 4532 1234 5678 9014, exp 09/27, CVV 321. Routing 021000021, account 9876543210. Call me at James Wilson, (212) 867-5309."
},
en3: {
title: "🇺🇸 Medical Intake (EN)",
body: "Patient: Maria Garcia, DOB 03/15/1985. Phone: (415) 692-7400. Email: m.garcia@protonmail.com. Address: 789 Oak Avenue, Suite 12, Portland, OR 97201. Insurance ID: XJ7742931. Passport: K0298847."
},
es1: {
title: "🇪🇸 Spanish (ES)",
body: "Hola, me llamo Carlos Rodríguez. Mi correo es carlos.rodriguez@hotmail.com y mi teléfono es +34 612 345 678. Vivo en la Calle Gran Via 45, 3ºB, Madrid, 28013."
},
fr1: {
title: "🇫🇷 French (FR)",
body: "Bonjour, je m'appelle Marie Dubois. Mon email est marie.dubois@orange.fr et mon numéro est 06 12 34 56 78. J'habite au 15 rue de Rivoli, Paris, 75001."
},
de1: {
title: "🇩🇪 German (DE)",
body: "Hallo, ich heiße Thomas Müller. Meine E-Mail ist t.muller@gmx.de und meine Nummer ist 0151 2345 6789. Ich wohne in der Hauptstraße 67, München, 80331."
},
it1: {
title: "🇮🇹 Italian (IT)",
body: "Ciao, mi chiamo Giulia Ferrari. La mia email è g.ferrari@libero.it e il mio numero è 347 1234567. Abito in via Roma 23, Milano, 20121."
},
pt1: {
title: "🇧🇷 Portuguese (PT)",
body: "Olá, meu nome é Pedro Santos. Meu email é pedro.santos@gmail.com e meu telefone é 11 98765-4321. Moro na Rua Augusta 1000, São Paulo, 01304."
},
nl1: {
title: "🇳🇱 Dutch (NL)",
body: "Hallo, ik heet Jan de Vries. Mijn email is j.devries@kpn.nl en mijn nummer is 06 12 34 56 78. Ik woon op de Keizersgracht 123, Amsterdam, 1015."
},
mixed1: {
title: "🌍 Mixed Languages",
body: "Meeting attendees: Pierre Lefèvre (pierre.lefevre@free.fr, +33 6 12 34 56 78), Hans Schmidt (h.schmidt@web.de, 0151 9988 7766), and Ana García (ana.garcia@gmail.com, 612 345 678). Location: 500 Market Street, San Francisco, CA 94105."
},
finance1: {
title: "💳 Banking Details",
body: "Wire transfer setup: Account holder Robert Chen, account DE89370400440532013000 (IBAN), routing number 026009593. Contact: robert.chen@company.com, 408-692-7400. SSN on file: 321-54-9876."
},
medical1: {
title: "🏥 Medical Intake",
body: "New patient registration: Name: Emily Thompson. Phone: (650) 421-8830. Email: emily.t@gmail.com. Address: 3421 Cedar Lane, Apt 7, Denver, CO 80205. Medicare ID: 1A2B3C4D5E. Driver's license: CO-1234567."
},
gov1: {
title: "🏛️ Government IDs",
body: "Visa application: Applicant John Smith, passport G28394716, born 07/04/1990. USCIS receipt MSC2390814. Phone: 202-456-1414. Email: jsmith2024@outlook.com. Address: 1600 Pennsylvania Ave NW, Washington, DC 20500."
},
};
// ===== STATE =====
let R = null; // rampart module ref
let nerClassifier = null; // loaded ONNX classifier
let nerDetector = null; // detectNer wrapper
let entityTable = null; // SessionEntityTable for live demo
let chatGuard = null;
let chatHistory = [];
let lastRedactedText = "";
let lastSpans = [];
// ===== LOAD MODEL =====
async function loadModel() {
const overlay = document.getElementById('loadingOverlay');
const status = document.getElementById('modelStatus');
const statusText = document.getElementById('modelStatusText');
const loadingText = document.getElementById('loadingText');
const loadingSub = document.getElementById('loadingSub');
const loadingBar = document.getElementById('loadingBar');
try {
status.className = 'model-status loading';
statusText.textContent = 'Loading model…';
loadingText.textContent = 'Loading Rampart model…';
loadingSub.textContent = '14.7 MB · ONNX Runtime Web · WASM';
loadingBar.style.width = '20%';
// Dynamically import the npm package from jsdelivr CDN
loadingText.textContent = 'Fetching package…';
loadingBar.style.width = '40%';
const rampart = await import('https://cdn.jsdelivr.net/npm/@nationaldesignstudio/rampart@0.1.2/dist/index.js');
loadingText.textContent = 'Initializing guard…';
loadingSub.textContent = 'Loading ONNX weights from Hugging Face Hub';
loadingBar.style.width = '60%';
R = rampart;
// Load the NER classifier once and share it
nerClassifier = await rampart.loadNerClassifier({ model: rampart.RAMPART_MODEL_ID, device: 'wasm' });
nerDetector = (text) => rampart.detectNer(text, nerClassifier);
entityTable = new rampart.SessionEntityTable();
// Create ChatGuard for chat simulation, sharing the same loaded model
chatGuard = await rampart.createGuard({ ner: nerDetector });
loadingBar.style.width = '100%';
status.className = 'model-status ready';
statusText.textContent = 'Model ready';
loadingText.textContent = 'Ready!';
setTimeout(() => {
overlay.classList.add('hidden');
setTimeout(() => overlay.style.display = 'none', 300);
}, 300);
// Run initial redaction if user already typed
const input = document.getElementById('inputBox');
if (input.value.trim()) {
runRedaction();
} else {
loadExample('en1');
}
} catch (err) {
console.error('Model load error:', err);
status.className = 'model-status error';
statusText.textContent = 'Failed to load';
loadingText.textContent = 'Failed to load model';
loadingSub.textContent = err.message || 'Unknown error';
overlay.style.display = 'none';
}
}
// ===== REDACTION =====
// Uses the lower-level Rampart pipeline to get both entity spans and redacted text.
// This replicates ChatGuard.detect() + SessionEntityTable.scrub() so we have
// access to the raw spans for visual highlighting.
let redactTimer = null;
async function detectSpans(text) {
// Step 1: Heuristic detection (structured PII: SSN, cards, emails, URLs, IPs)
const heuristicSpans = R.detectHeuristics(text);
// Step 2: Premask structured PII before the model sees it
const map = R.premask(text, heuristicSpans);
// Step 3: NER detection on the premasked text
const maskedSpans = await nerDetector(map.masked);
// Step 4: Project model spans from masked coordinates back to raw offsets
const contextualSpans = [];
for (const span of maskedSpans) {
const projected = R.projectMaskedSpan(span, text, map);
if (projected !== null) contextualSpans.push(projected);
}
// Step 5: Merge heuristic + model spans
const allSpans = R.mergeSpans([...heuristicSpans, ...contextualSpans]);
// Step 6: Apply policy (keep CITY, STATE, ZIP_CODE)
const redactable = R.applyPolicy(allSpans, R.KEEP_LABELS);
return { allSpans, redactable };
}
async function runRedaction() {
if (!nerDetector) return;
const text = document.getElementById('inputBox').value;
if (!text.trim()) {
document.getElementById('outputBox').className = 'output-box empty';
document.getElementById('outputBox').textContent = 'Start typing above to see PII get redacted…';
document.getElementById('statsBar').style.display = 'none';
document.getElementById('mappingList').innerHTML = '<div class="mapping-empty">Type text in the demo above to see placeholder mappings…</div>';
lastRedactedText = "";
lastSpans = [];
return;
}
const start = performance.now();
try {
const { allSpans, redactable } = await detectSpans(text);
const elapsed = performance.now() - start;
// Use a fresh entity table for each redaction in the live demo
// (Chat simulation maintains its own persistent table)
const table = new R.SessionEntityTable();
const result = table.scrub(text, redactable);
lastRedactedText = result.text;
// For highlighting, we use all detected spans (including kept ones)
// We need to map each redactable span to its placeholder
const placeholderMap = new Map();
for (const span of redactable) {
const ph = table.placeholderFor(span.label, span.text);
placeholderMap.set(`${span.start}:${span.end}`, ph);
}
// Build spans with placeholder info for rendering
const renderSpans = allSpans.map(s => {
const isKept = !R.shouldRedact(s.label, R.KEEP_LABELS);
const ph = placeholderMap.get(`${s.start}:${s.end}`);
return {
label: s.label,
start: s.start,
end: s.end,
text: s.text,
source: s.source,
score: s.score,
placeholder: ph,
isKept
};
});
lastSpans = renderSpans;
renderHighlighted(text, renderSpans, elapsed);
renderStats(renderSpans, elapsed);
renderMapping();
} catch (err) {
console.error('Redaction error:', err);
document.getElementById('outputBox').className = 'output-box empty';
document.getElementById('outputBox').textContent = 'Error: ' + (err.message || err);
}
}
function renderHighlighted(original, spans, elapsed) {
const outputBox = document.getElementById('outputBox');
if (spans.length === 0) {
outputBox.className = 'output-box empty';
outputBox.innerHTML = 'No PII detected — this text is safe to send.';
return;
}
// Sort spans by start position
const sorted = [...spans].sort((a, b) => a.start - b.start);
outputBox.className = 'output-box';
// Build highlighted HTML from original text with spans highlighted
let html = '';
let lastEnd = 0;
for (const span of sorted) {
// Text before this span
if (span.start > lastEnd) {
html += escapeHtml(original.substring(lastEnd, span.start));
}
// Skip overlapping spans (shouldn't happen after merge, but be safe)
if (span.start < lastEnd) continue;
const info = ENTITY_TYPES[span.label] || { color: '#888' };
const isKept = span.isKept;
const displayText = isKept ? span.text : (span.placeholder || `[${span.label}]`);
const borderOpacity = isKept ? '44' : '66';
html += `<span class="entity-highlight" style="background:${info.color}22;color:${info.color};border:1px solid ${info.color}${borderOpacity}" data-label="${span.label}" data-original="${escapeAttr(span.text)}" data-score="${span.score}" data-kept="${isKept}">${escapeHtml(displayText)}</span>`;
lastEnd = span.end;
}
if (lastEnd < original.length) {
html += escapeHtml(original.substring(lastEnd));
}
outputBox.innerHTML = html;
// Add tooltip handlers
outputBox.querySelectorAll('.entity-highlight').forEach(el => {
el.addEventListener('mouseenter', showTooltip);
el.addEventListener('mouseleave', hideTooltip);
});
}
function renderStats(spans, elapsed) {
const statsBar = document.getElementById('statsBar');
statsBar.style.display = 'flex';
const redacted = spans.filter(s => !s.isKept);
const retained = spans.filter(s => s.isKept);
document.getElementById('entityCount').textContent = spans.length;
document.getElementById('redactCount').textContent = redacted.length;
document.getElementById('retainCount').textContent = retained.length;
document.getElementById('latency').textContent = Math.round(elapsed || 0);
}
function renderMapping() {
const spans = lastSpans;
const list = document.getElementById('mappingList');
const redacted = spans.filter(s => !s.isKept && s.placeholder);
if (redacted.length === 0) {
list.innerHTML = '<div class="mapping-empty">No redacted entities to map. Type text with PII in the demo above.</div>';
return;
}
list.innerHTML = redacted.map(s => {
const info = ENTITY_TYPES[s.label] || { color: '#888' };
return `<div class="mapping-row">
<span class="mapping-placeholder" style="color:${info.color}">${escapeHtml(s.placeholder)}</span>
<span class="mapping-arrow">→</span>
<span class="mapping-value">${escapeHtml(s.text)}</span>
</div>`;
}).join('');
}
// ===== TOOLTIP =====
function showTooltip(e) {
const el = e.target;
const label = el.dataset.label;
const original = el.dataset.original;
const score = el.dataset.score;
const info = ENTITY_TYPES[label] || { color: '#888', desc: label };
const isKept = el.dataset.kept === 'true';
const tooltip = document.getElementById('tooltip');
tooltip.innerHTML = `
<div class="tt-label" style="color:${info.color}">${label}</div>
<div class="tt-original">${escapeHtml(original)}</div>
${isKept ? '<div class="tt-score" style="color:var(--green)">✓ Retained for context</div>' : '<div class="tt-score">🔒 Redacted</div>'}
<div class="tt-score">Source: ${el.dataset.score >= 1 ? 'Deterministic' : 'Model'} (${parseFloat(el.dataset.score).toFixed(2)})</div>
`;
tooltip.classList.add('visible');
const rect = el.getBoundingClientRect();
tooltip.style.left = rect.left + (rect.width / 2) - 100 + 'px';
tooltip.style.top = (rect.top - tooltip.offsetHeight - 8) + 'px';
// Adjust if off-screen
requestAnimationFrame(() => {
const ttRect = tooltip.getBoundingClientRect();
if (ttRect.left < 8) tooltip.style.left = '8px';
if (ttRect.right > window.innerWidth - 8) tooltip.style.left = (window.innerWidth - ttRect.width - 8) + 'px';
if (ttRect.top < 8) tooltip.style.top = (rect.bottom + 8) + 'px';
});
}
function hideTooltip() {
document.getElementById('tooltip').classList.remove('visible');
}
// ===== UTILITIES =====
function escapeHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function escapeAttr(s) {
return s.replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// ===== COPY =====
window.copyRedacted = function() {
if (!lastRedactedText) return;
navigator.clipboard.writeText(lastRedactedText).then(() => {
const btn = document.getElementById('copyBtn');
btn.classList.add('copied');
setTimeout(() => btn.classList.remove('copied'), 1500);
});
};
// ===== LOAD EXAMPLE =====
window.loadExample = function(id) {
const ex = EXAMPLES[id];
if (!ex) return;
document.getElementById('inputBox').value = ex.body;
runRedaction();
};
window.clearInput = function() {
document.getElementById('inputBox').value = '';
runRedaction();
};
// ===== TABS =====
window.switchTab = function(e, tabId) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
e.target.classList.add('active');
document.getElementById('tab-' + tabId).classList.add('active');
if (tabId === 'mapping') renderMapping();
};
// ===== CHAT SIMULATION =====
window.sendChat = async function() {
if (!chatGuard) return;
const input = document.getElementById('chatInput');
const text = input.value.trim();
if (!text) return;
input.value = '';
const messages = document.getElementById('chatMessages');
// Show user message
const userMsg = document.createElement('div');
userMsg.className = 'chat-msg user';
userMsg.textContent = text;
messages.appendChild(userMsg);
// Show what the AI sees (redacted)
const result = await chatGuard.protect(text);
const redactedMsg = document.createElement('div');
redactedMsg.className = 'chat-msg user-redacted';
redactedMsg.style.fontSize = '13px';
redactedMsg.innerHTML = `🤖 AI sees: <span style="font-family:var(--mono);color:var(--accent)">${escapeHtml(result.text)}</span>`;
messages.appendChild(redactedMsg);
messages.scrollTop = messages.scrollHeight;
// Simulate AI response
await new Promise(r => setTimeout(r, 500));
const aiReply = simulateAIReply(result.text);
const aiMsg = document.createElement('div');
aiMsg.className = 'chat-msg assistant';
// Rehydrate placeholders in the response
const rehydrated = chatGuard.reveal(aiReply);
aiMsg.innerHTML = escapeHtml(rehydrated);
messages.appendChild(aiMsg);
messages.scrollTop = messages.scrollHeight;
};
window.resetChat = function() {
chatHistory = [];
document.getElementById('chatMessages').innerHTML =
'<div class="chat-msg assistant">Chat reset. Type a message below to start a new conversation.</div>';
};
function simulateAIReply(redactedText) {
const hasName = /\[GIVEN_NAME_\d+\]/.test(redactedText);
const hasPhone = /\[PHONE_\d+\]/.test(redactedText);
const hasEmail = /\[EMAIL_\d+\]/.test(redactedText);
const hasSsn = /\[SSN_\d+\]/.test(redactedText);
let parts = [];
if (hasName) parts.push("Hello [GIVEN_NAME_1]!");
else parts.push("Hello!");
if (hasPhone && hasEmail) parts.push(" I've noted your phone number and email address.");
else if (hasPhone) parts.push(" I've noted your phone number.");
else if (hasEmail) parts.push(" I've noted your email address.");
if (hasSsn) parts.push(" Your SSN is on file and will be kept secure.");
if (parts.length === 1) parts.push(" How can I help you today?");
return parts.join('');
}
// ===== RENDER LEGEND =====
function renderLegend() {
const grid = document.getElementById('legendGrid');
const order = [
'SSN','CREDIT_CARD','EMAIL','URL','IP_ADDRESS',
'GIVEN_NAME','SURNAME','PHONE','TAX_ID','BANK_ACCOUNT','ROUTING_NUMBER',
'GOVERNMENT_ID','PASSPORT','DRIVERS_LICENSE',
'BUILDING_NUMBER','STREET_NAME','SECONDARY_ADDRESS',
'CITY','STATE','ZIP_CODE'
];
grid.innerHTML = order.map(label => {
const info = ENTITY_TYPES[label];
const isKept = KEPT_LABELS.has(label);
return `<div class="legend-item">
<span class="legend-swatch" style="background:${info.color}"></span>
<div>
<div class="legend-label" style="color:${info.color}">${label}${isKept ? ' ✓' : ''}</div>
<div class="legend-desc">${info.desc} · ${info.layer}</div>
</div>
</div>`;
}).join('');
}
// ===== RENDER EXAMPLES GRID =====
function renderExamples() {
const grid = document.getElementById('examplesGrid');
grid.innerHTML = Object.entries(EXAMPLES).map(([id, ex]) => {
return `<div class="example-card" onclick="loadExample('${id}')">
<div class="example-card-title">${ex.title}</div>
<div class="example-card-body">${escapeHtml(ex.body)}</div>
</div>`;
}).join('');
}
// ===== INIT =====
renderLegend();
renderExamples();
// Input listener with debounce
document.getElementById('inputBox').addEventListener('input', () => {
clearTimeout(redactTimer);
redactTimer = setTimeout(runRedaction, 200);
});
// Start model loading
loadModel();
</script>
</body>
</html>