Spaces:
Running
Running
| <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 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, Apt 5B, 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://huggingface.co/nationaldesignstudio/rampart" target="_blank" rel="noopener">Rampart Model ↗</a> | |
| <a href="https://www.npmjs.com/package/@nationaldesignstudio/rampart" target="_blank" rel="noopener">npm ↗</a> | |
| <a href="https://huggingface.co/datasets/ai4privacy/pii-masking-openpii-1.5m" target="_blank" rel="noopener">Training Data ↗</a> | |
| <a href="https://huggingface.co/docs/transformers.js" target="_blank" rel="noopener">transformers.js ↗</a> | |
| <a href="https://ndstudio.gov" target="_blank" rel="noopener">ndstudio.gov ↗</a> | |
| </div> | |
| <div class="f-legal"> | |
| <span>Rampart model — National Design Studio. CC BY 4.0.</span> | |
| <span>Demo build by Mike0021. 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, Apt 5B, 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.1/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,'&').replace(/</g,'<').replace(/>/g,'>'); | |
| } | |
| function escapeAttr(s) { | |
| return s.replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>'); | |
| } | |
| // ===== 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> | |