@@ -538,7 +664,7 @@
Every subscription,
one calm surface.
UNSYPHN pulls every SaaS seat, invoice, and renewal your company runs into a single liquid vault — so finance, IT, and ops finally see the same picture.
-
+
@@ -623,21 +749,55 @@
One vault. Every renewal.
];
const LETTERS = 'AZSNGFLVRMZGDHJAID'.split('');
+ // 54 SaaS vendor logos — Simple Icons CDN, served in each brand's official hex color.
+ // [slug, brand-hex] tuples → https://cdn.simpleicons.org/{slug}/{hex}
+ // Trademark-blocked brands (Slack/MS/Adobe/Salesforce/AWS/Segment/Amplitude/Docusign/Monday)
+ // are swapped for working B2B SaaS slugs of similar category.
+ const VENDOR_LOGOS = [
+ ['notion','000000'], ['discord','5865F2'], ['figma','F24E1E'],
+ ['clickup','7B68EE'], ['trello','0079BF'], ['stripe','635BFF'],
+ ['datadog','632CA6'], ['github','181717'], ['cloudflare','F38020'],
+ ['googlecloud','4285F4'], ['vercel','000000'], ['linear','5E6AD2'],
+ ['jira','0052CC'], ['atlassian','0052CC'], ['asana','F06A6A'],
+ ['basecamp','1D2D35'], ['hubspot','FF7A59'], ['mailchimp','FFE01B'],
+ ['intercom','0057FF'], ['zendesk','03363D'], ['retool','3D3D3D'],
+ ['mixpanel','7856FF'], ['grafana','F46800'], ['posthog','1D4AFF'],
+ ['zoom','0B5CFF'], ['googlecalendar','4285F4'], ['gmail','EA4335'],
+ ['zapier','FF4A00'], ['make','6D00CC'], ['dropbox','0061FF'],
+ ['box','0061D5'], ['googledrive','4285F4'], ['ifttt','000000'],
+ ['supabase','3FCF8E'], ['planetscale','000000'], ['sketch','F7B500'],
+ ['framer','0055FF'], ['miro','FFD02F'], ['loom','625DF5'],
+ ['calendly','006BFF'], ['shopify','7AB55C'], ['brex','D9A227'],
+ ['okta','007DC1'], ['auth0','EB5424'], ['1password','0572EC'],
+ ['lastpass','D32D27'], ['bitwarden','175DDC'], ['snowflake','29B5E8'],
+ ['mongodb','47A248'], ['postgresql','4169E1'], ['redis','DC382D'],
+ ['sentry','362D59'], ['pagerduty','06AC38'], ['airtable','FCB400']
+ ];
+
// helper: cube edge size in px (set on init + resize)
function getS(){
const v = parseFloat(getComputedStyle(cube).getPropertyValue('--S'));
return isFinite(v) && v > 0 ? v : 420;
}
- // create face backdrops, each carrying the UNSYPHN wordmark
+ // create face backdrops with the brand mark + wordmark centered behind the tiles —
+ // as tiles disperse on scroll, the logo and the UNSYPHN wordmark emerge.
FACES.forEach(([cls, N, R, U], fi)=>{
const f = document.createElement('div');
f.className = 'face ' + cls;
f.style.setProperty('--glow', AURORA[fi % AURORA.length]);
- const wm = document.createElement('span');
- wm.className = 'wordmark';
- wm.textContent = 'UNSYPHN';
- f.appendChild(wm);
+ const logo = document.createElement('img');
+ logo.className = 'face-logo';
+ logo.src = '/unsyphn-mark.png';
+ logo.alt = '';
+ logo.setAttribute('aria-hidden', 'true');
+ logo.loading = 'lazy';
+ f.appendChild(logo);
+ const wordmark = document.createElement('div');
+ wordmark.className = 'face-wordmark';
+ wordmark.textContent = 'UNSYPHN';
+ wordmark.setAttribute('aria-hidden', 'true');
+ f.appendChild(wordmark);
cube.appendChild(f);
});
@@ -651,9 +811,12 @@
One vault. Every renewal.
el.className = 'tile ' + cls;
const color = AURORA[(fi * 3 + (row + col)) % AURORA.length];
el.style.setProperty('--glow', color);
+ const [slug, hex] = VENDOR_LOGOS[tileIdx % VENDOR_LOGOS.length];
el.innerHTML =
tileHTML(tileIdx) +
- '
' + LETTERS[tileIdx % LETTERS.length] + '';
+ '
![]()
';
cube.appendChild(el);
// base local 2D offset on the face
@@ -664,7 +827,7 @@
One vault. Every renewal.
const sxA = Math.random()*2 - 1;
const syA = Math.random()*2 - 1;
const szA = Math.random()*2 - 1;
- const spinMax = 220 + Math.random()*260;
+ const spinMax = 80 + Math.random()*120;
const stagger = Math.random() * 0.18;
TILES.push({
@@ -712,7 +875,9 @@
One vault. Every renewal.
// when rAF is throttled (hidden tab, pre-first-frame, etc).
function renderTiles(sp){
const S = getS();
- const scatter = S * 1.65;
+ // softer dispersal — tiles drift only ~55% of cube edge at peak so the cube reads
+ // intact for most of the scroll, then opens up gently near the bottom of the hero
+ const scatter = S * 0.55;
for(let i = 0; i < TILES.length; i++){
const t = TILES[i];
const tp = Math.max(0, Math.min(1, (sp - t.stagger) / (1 - t.stagger)));
@@ -728,9 +893,10 @@
One vault. Every renewal.
`rotateX(${t.baseRx}deg) rotateY(${t.baseRy}deg) ` +
`rotateX(${sxR.toFixed(2)}deg) rotateY(${syR.toFixed(2)}deg) rotateZ(${szR.toFixed(2)}deg)`;
}
- // dim the backdrop faces as tiles fly off
- const faceOpacity = (1 - sp * 0.85).toFixed(3);
- document.querySelectorAll('.face').forEach(f => f.style.opacity = faceOpacity);
+ // face stays solid white — as tiles fly off, the centered brand mark + wordmark reveal.
+ // opacity ramps 0.55 → 1 to make the reveal feel intentional.
+ const brandOpacity = (0.55 + sp * 0.45).toFixed(3);
+ document.querySelectorAll('.face-logo, .face-wordmark').forEach(l => l.style.opacity = brandOpacity);
}
computeSizing();
@@ -817,6 +983,168 @@
One vault. Every renewal.
requestAnimationFrame(frame);
}
})();
+
+/* ─────────────────────────────────────────────
+ AMBIENT DOTS — slow-drifting halftone field
+ inspired by the brand mark, layered behind everything
+ ───────────────────────────────────────────── */
+(function(){
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
+ var canvas = document.getElementById('ambient-dots');
+ if (!canvas) return;
+ var ctx = canvas.getContext('2d');
+ var dpr = window.devicePixelRatio || 1;
+
+ // brand-mark palette (purple → blue) — same as cursor trail for cohesion
+ var palette = ['#2E0F88','#3B1AAE','#5B36D6','#6E4DEA','#7855F0','#5E6AD2','#5B8AF0','#2BCAE8','#4FB8C9'];
+
+ var dots = [];
+ // density per million pixels of viewport — ~110 dots on a 1440x900 screen
+ var DENSITY = 85e-6;
+
+ function rebuild(){
+ canvas.width = window.innerWidth * dpr;
+ canvas.height = window.innerHeight * dpr;
+ canvas.style.width = window.innerWidth + 'px';
+ canvas.style.height = window.innerHeight + 'px';
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+
+ var w = window.innerWidth, h = window.innerHeight;
+ var count = Math.max(40, Math.round(w * h * DENSITY));
+ dots = new Array(count);
+ for (var i = 0; i < count; i++){
+ // slow drift — 6–18 px/sec
+ var angle = Math.random() * Math.PI * 2;
+ var speed = 0.006 + Math.random() * 0.012; // px per ms
+ dots[i] = {
+ x: Math.random() * w,
+ y: Math.random() * h,
+ r: 1.2 + Math.random() * 2.8, // 1.2 – 4 px
+ vx: Math.cos(angle) * speed,
+ vy: Math.sin(angle) * speed,
+ color: palette[(Math.random() * palette.length) | 0],
+ // each dot has its own twinkle phase for a soft alpha ripple
+ twinkle: Math.random() * Math.PI * 2,
+ twinkleSpeed: 0.0008 + Math.random() * 0.0014, // rad per ms
+ };
+ }
+ }
+ rebuild();
+ // re-seed on resize so density stays consistent
+ var rt;
+ window.addEventListener('resize', function(){
+ clearTimeout(rt);
+ rt = setTimeout(rebuild, 180);
+ });
+
+ var prev = performance.now();
+ function tick(now){
+ var dt = now - prev; prev = now;
+ var w = window.innerWidth, h = window.innerHeight;
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ for (var i = 0; i < dots.length; i++){
+ var d = dots[i];
+ d.x += d.vx * dt;
+ d.y += d.vy * dt;
+ // wrap to opposite edge (with small margin so they don't pop)
+ if (d.x < -8) d.x = w + 8;
+ else if (d.x > w + 8) d.x = -8;
+ if (d.y < -8) d.y = h + 8;
+ else if (d.y > h + 8) d.y = -8;
+
+ d.twinkle += d.twinkleSpeed * dt;
+ // alpha oscillates 0.45 – 1.0 — combined with canvas opacity 0.30, this gives
+ // a real-world feel of dots fading in and out across the field
+ var alpha = 0.7 + Math.sin(d.twinkle) * 0.28;
+
+ ctx.beginPath();
+ ctx.fillStyle = d.color;
+ ctx.globalAlpha = alpha;
+ ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.globalAlpha = 1;
+ requestAnimationFrame(tick);
+ }
+ requestAnimationFrame(tick);
+})();
+
+/* ─────────────────────────────────────────────
+ CURSOR TRAIL — dot pattern echoing the brand mark
+ ───────────────────────────────────────────── */
+(function(){
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
+ var canvas = document.getElementById('cursor-trail');
+ if (!canvas) return;
+ var ctx = canvas.getContext('2d');
+ var dpr = window.devicePixelRatio || 1;
+
+ function sizeCanvas(){
+ canvas.width = window.innerWidth * dpr;
+ canvas.height = window.innerHeight * dpr;
+ canvas.style.width = window.innerWidth + 'px';
+ canvas.style.height = window.innerHeight + 'px';
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ }
+ sizeCanvas();
+ window.addEventListener('resize', sizeCanvas);
+
+ // Brand-mark palette — purple → blue gradient
+ var palette = ['#2E0F88','#3B1AAE','#5B36D6','#6E4DEA','#7855F0','#5E6AD2','#5B8AF0','#2BCAE8','#4FB8C9'];
+
+ var particles = [];
+ var lastEmit = 0;
+ var lastX = 0, lastY = 0;
+ var hasMoved = false;
+
+ window.addEventListener('pointermove', function(ev){
+ var now = performance.now();
+ var dx = ev.clientX - lastX, dy = ev.clientY - lastY;
+ var dist = Math.hypot(dx, dy);
+ lastX = ev.clientX; lastY = ev.clientY;
+ hasMoved = true;
+ // throttle harder — emit at most every ~40ms or when moved 14+px
+ if (now - lastEmit < 40 && dist < 14) return;
+ lastEmit = now;
+ // at most 1 dot per emission — keeps the trail whispered, not sprayed
+ var off = (Math.random() - 0.5) * Math.min(16, dist * 0.4 + 4);
+ var perp = (Math.random() - 0.5) * 8;
+ var nx = dist > 0.001 ? (-dy / dist) : 0;
+ var ny = dist > 0.001 ? ( dx / dist) : 0;
+ particles.push({
+ x: ev.clientX + off + nx * perp,
+ y: ev.clientY + off + ny * perp,
+ r: 1.5 + Math.random() * 2.5, // 1.5 – 4px (was 2 – 9)
+ color: palette[(Math.random() * palette.length) | 0],
+ age: 0,
+ life: 520 + Math.random() * 380, // 520 – 900ms (was 900 – 1600)
+ });
+ if (particles.length > 90) particles.splice(0, particles.length - 90);
+ }, { passive: true });
+
+ var prev = performance.now();
+ function tick(now){
+ var dt = now - prev; prev = now;
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ for (var i = particles.length - 1; i >= 0; i--){
+ var p = particles[i];
+ p.age += dt;
+ var t = p.age / p.life;
+ if (t >= 1){ particles.splice(i, 1); continue; }
+ // gentler fade — peak alpha ~0.5, decays smoothly
+ var alpha = (1 - t) * (1 - t * 0.6) * 0.55;
+ var radius = p.r * (1 - t * 0.4);
+ ctx.beginPath();
+ ctx.fillStyle = p.color;
+ ctx.globalAlpha = alpha;
+ ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.globalAlpha = 1;
+ requestAnimationFrame(tick);
+ }
+ requestAnimationFrame(tick);
+})();
diff --git a/apps/web/package.json b/apps/web/package.json
index 0795f27..e7e3976 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,5 +1,5 @@
{
- "name": "@redline/web",
+ "name": "@unsyphn/web",
"version": "0.1.0",
"private": true,
"type": "module",
@@ -10,10 +10,16 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.4.0",
- "@redline/shared": "workspace:*",
"@stripe/react-stripe-js": "^3.0.0",
"@stripe/stripe-js": "^4.10.0",
+ "@types/canvas-confetti": "^1.9.0",
+ "@unsyphn/shared": "workspace:*",
+ "canvas-confetti": "^1.9.4",
+ "lucide-react": "^1.16.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-hook-form": "^7.53.0",
diff --git a/apps/web/public/app/app.css b/apps/web/public/app/app.css
deleted file mode 100644
index edb7edb..0000000
--- a/apps/web/public/app/app.css
+++ /dev/null
@@ -1,484 +0,0 @@
-/* =============================================================
- UNSYPHN APP · stylesheet
- Aurora pattern — solid surfaces, role-tinted radial glows
- ============================================================= */
-
-@import url("https://fonts.googleapis.com/css2?family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,500;0,8..60,700;0,8..60,800;1,8..60,400&display=swap");
-
-:root { --serif: "Source Serif 4", ui-serif, Georgia, serif; }
-
-*, *::before, *::after { box-sizing: border-box; }
-html, body { margin: 0; padding: 0; font-family: var(--font-text); color: var(--text); }
-
-::selection { background: var(--bondi); color: var(--bg); }
-button { font-family: inherit; }
-
-/* ── Aurora sky on the app body ────────────────────────── */
-.app-shell {
- width: 1440px;
- height: 900px;
- position: relative;
- overflow: hidden;
- background:
- radial-gradient(ellipse 800px 500px at 80% -5%, rgba(91,160,240,0.20) 0%, transparent 55%),
- radial-gradient(ellipse 700px 450px at 10% 15%, rgba(43,202,232,0.12) 0%, transparent 55%),
- radial-gradient(ellipse 600px 450px at 95% 50%, rgba(160,151,216,0.18) 0%, transparent 55%),
- radial-gradient(ellipse 700px 400px at 5% 70%, rgba(244,102,136,0.10) 0%, transparent 55%),
- radial-gradient(ellipse 800px 500px at 70% 95%, rgba(181,220,85,0.10) 0%, transparent 55%),
- var(--bg);
- color: var(--text);
- font-size: 14px;
- line-height: 1.55;
- -webkit-font-smoothing: antialiased;
- display: grid;
- grid-template-columns: 220px 1fr;
-}
-
-/* ── Sidebar ───────────────────────────────────────────── */
-.sidebar {
- padding: 22px 12px;
- border-right: 1px solid var(--border);
- background: var(--bg);
- display: flex; flex-direction: column; gap: 6px;
- height: 900px; overflow: hidden;
-}
-.brand-row {
- display: flex; align-items: center; padding: 6px 10px;
- margin-bottom: 18px;
-}
-.nav-section-label {
- font-family: var(--font-mono);
- font-size: 10px; letter-spacing: 0.12em;
- text-transform: uppercase; color: var(--muted);
- padding: 12px 10px 6px;
-}
-.nav-item {
- display: flex; align-items: center; justify-content: space-between;
- padding: 7px 10px;
- border-radius: var(--radius-md);
- color: var(--text-2);
- font-size: 13px;
- font-weight: 500;
- cursor: pointer;
- transition: all var(--dur-fast) var(--ease);
- user-select: none;
-}
-.nav-item:hover { background: var(--surface); color: var(--text); }
-.nav-item.is-active {
- background: var(--surface);
- color: var(--text-strong);
- border: 1px solid var(--border);
-}
-.nav-item .count {
- font-family: var(--font-mono); font-size: 11px;
- color: var(--muted);
- background: var(--surface-2);
- padding: 1px 6px;
- border-radius: var(--radius-sm);
-}
-.nav-item.is-active .count { color: var(--text); }
-.nav-item.alert .count { background: var(--strawberry-soft); color: var(--strawberry); }
-.nav-spacer { flex: 1; }
-.nav-org {
- padding: 10px 10px;
- border-top: 1px solid var(--border);
- margin-top: 6px;
-}
-.org-name { font-size: 12.5px; font-weight: 500; color: var(--text); }
-.org-meta { font-family: var(--font-mono); font-size: 10px; color: var(--muted); letter-spacing: 0.06em; margin-top: 2px; }
-
-/* ── Main ─────────────────────────────────────────────── */
-.main {
- padding: 28px 36px;
- min-width: 0;
- overflow-y: auto;
- height: 900px;
- scrollbar-width: thin;
- scrollbar-color: var(--border-strong) transparent;
-}
-.main::-webkit-scrollbar { width: 8px; }
-.main::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
-.main::-webkit-scrollbar-track { background: transparent; }
-
-/* ── Topbar (app-wide) ────────────────────────────────── */
-.topbar {
- display: flex; justify-content: space-between; align-items: center;
- margin-bottom: 24px;
-}
-.topbar-left h1 {
- font-family: var(--font-display);
- font-size: 28px; font-weight: 500;
- color: var(--text-strong);
- letter-spacing: -0.025em;
- margin: 0 0 4px;
- line-height: 1.1;
- font-variation-settings: "opsz" 48;
-}
-.topbar-left .when {
- font-family: var(--font-mono);
- font-size: 11px; color: var(--muted);
- letter-spacing: 0.10em;
- text-transform: uppercase;
-}
-.topbar-right { display: flex; gap: 10px; align-items: center; }
-.scan-pill {
- display: inline-flex; align-items: center; gap: 7px;
- padding: 5px 11px;
- border-radius: var(--radius-full);
- background: var(--surface);
- border: 1px solid var(--border);
- font-family: var(--font-mono);
- font-size: 10.5px; color: var(--text-2);
- letter-spacing: 0.10em;
- text-transform: uppercase;
-}
-.scan-pill .dot {
- width: 6px; height: 6px; border-radius: 50%;
- background: var(--bondi);
- box-shadow: 0 0 0 3px rgba(43,202,232,0.18), 0 0 10px var(--bondi);
-}
-.scan-pill.alert {
- background: var(--strawberry-soft);
- color: var(--strawberry);
- border-color: rgba(244,102,136,0.30);
-}
-.user-avatar {
- width: 30px; height: 30px;
- border-radius: 50%;
- background: linear-gradient(135deg, var(--grape), var(--strawberry));
- display: flex; align-items: center; justify-content: center;
- font-family: var(--font-mono);
- font-size: 10px; font-weight: 600; color: white;
- box-shadow: 0 0 0 1px var(--border), 0 0 14px rgba(160,151,216,0.20);
-}
-
-/* Live-dot pulse (used everywhere) */
-.live-dot.pulse { animation: pulse 1.6s ease-in-out infinite; }
-@keyframes pulse {
- 0%, 100% { opacity: 1; }
- 50% { opacity: 0.50; }
-}
-@keyframes blink {
- 0%, 100% { opacity: 1; }
- 50% { opacity: 0.35; }
-}
-
-/* ── Live scan ticker ─────────────────────────────────── */
-.scan-strip {
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius-lg);
- padding: 11px 0;
- overflow: hidden;
- position: relative;
- box-shadow: 0 8px 32px rgba(0,0,0,0.20);
- margin-bottom: 20px;
-}
-.scan-strip::before, .scan-strip::after {
- content: ""; position: absolute; top: 0; bottom: 0; width: 80px; z-index: 2; pointer-events: none;
-}
-.scan-strip::before { left: 0; background: linear-gradient(90deg, var(--surface) 0%, transparent 100%); }
-.scan-strip::after { right: 0; background: linear-gradient(-90deg, var(--surface) 0%, transparent 100%); }
-.scan-strip-label {
- position: absolute;
- top: 50%; left: 14px;
- transform: translateY(-50%);
- font-family: var(--font-mono);
- font-size: 10px;
- letter-spacing: 0.14em;
- text-transform: uppercase;
- color: var(--bondi);
- background: var(--bondi-soft);
- padding: 4px 9px;
- border-radius: var(--radius-sm);
- border: 1px solid rgba(43,202,232,0.18);
- z-index: 3;
- display: flex; align-items: center; gap: 6px;
-}
-.scan-strip-label .dot {
- width: 5px; height: 5px; border-radius: 50%;
- background: var(--bondi);
- box-shadow: 0 0 8px var(--bondi);
-}
-.scan-track {
- display: inline-flex;
- white-space: nowrap;
- animation: tick 50s linear infinite;
- padding-left: 200px;
-}
-@keyframes tick {
- from { transform: translateX(0); }
- to { transform: translateX(-50%); }
-}
-.scan-item {
- display: inline-flex; align-items: center; gap: 10px;
- padding: 0 22px;
- font-family: var(--font-mono);
- font-size: 11.5px;
- border-right: 1px solid var(--hairline);
- color: var(--text-2);
- letter-spacing: 0.04em;
-}
-.scan-item .vendor { color: var(--text); font-weight: 500; }
-.scan-item .target { color: var(--muted); }
-.scan-item .when { color: var(--bondi); font-size: 10px; }
-
-/* ── Fleet stats grid ─────────────────────────────────── */
-.fleet {
- display: grid;
- grid-template-columns: repeat(6, 1fr);
- gap: 12px;
- margin-bottom: 28px;
-}
-.stat {
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius-lg);
- padding: 14px;
- position: relative;
- overflow: hidden;
- transition: transform var(--dur-base) var(--ease-spring), border-color var(--dur-base) var(--ease);
- cursor: pointer;
-}
-.stat:hover { transform: translateY(-2px); border-color: var(--border-strong); }
-.stat::before {
- content: ""; position: absolute;
- top: -50%; right: -20%; width: 80%; height: 100%;
- background: radial-gradient(circle, var(--stat-glow) 0%, transparent 60%);
- opacity: 0.45; pointer-events: none;
-}
-.stat-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; position: relative; }
-.stat-label {
- font-family: var(--font-mono); font-size: 10px;
- letter-spacing: 0.10em; text-transform: uppercase;
- color: var(--muted);
-}
-.stat-dot {
- width: 7px; height: 7px; border-radius: 50%;
- box-shadow: 0 0 12px var(--stat-shadow), 0 0 0 3px var(--stat-glow);
-}
-.stat-val {
- font-family: var(--font-display);
- font-size: 28px;
- font-weight: 500;
- letter-spacing: -0.025em;
- color: var(--text-strong);
- line-height: 1;
- margin-bottom: 4px;
- font-feature-settings: "tnum" 1;
- position: relative;
-}
-.stat-sub {
- font-family: var(--font-mono);
- font-size: 10px;
- color: var(--stat-color);
- letter-spacing: 0.04em;
- position: relative;
-}
-.stat.aqua { --stat-glow: var(--aqua-glow); --stat-shadow: var(--aqua); --stat-color: var(--aqua); } .stat.aqua .stat-dot { background: var(--aqua); }
-.stat.bondi { --stat-glow: var(--bondi-glow); --stat-shadow: var(--bondi); --stat-color: var(--bondi); } .stat.bondi .stat-dot { background: var(--bondi); }
-.stat.tangerine { --stat-glow: var(--tangerine-glow); --stat-shadow: var(--tangerine); --stat-color: var(--tangerine); } .stat.tangerine .stat-dot { background: var(--tangerine); }
-.stat.grape { --stat-glow: var(--grape-glow); --stat-shadow: var(--grape); --stat-color: var(--grape); } .stat.grape .stat-dot { background: var(--grape); }
-.stat.strawberry { --stat-glow: var(--strawberry-glow); --stat-shadow: var(--strawberry); --stat-color: var(--strawberry); } .stat.strawberry .stat-dot { background: var(--strawberry); }
-.stat.lime { --stat-glow: var(--lime-glow); --stat-shadow: var(--lime); --stat-color: var(--lime); } .stat.lime .stat-dot { background: var(--lime); }
-
-/* ── Section heads ────────────────────────────────────── */
-.sec-head {
- display: flex; justify-content: space-between; align-items: baseline;
- margin: 0 0 14px;
-}
-.sec-title {
- font-family: var(--font-display);
- font-size: 19px;
- font-weight: 500;
- color: var(--text-strong);
- letter-spacing: -0.018em;
- margin: 0;
-}
-.sec-title em {
- font-family: var(--serif);
- font-style: italic;
- font-weight: 400;
- color: var(--text);
-}
-.sec-action {
- font-family: var(--font-mono);
- font-size: 11px;
- color: var(--bondi);
- cursor: pointer;
- letter-spacing: 0.10em;
- text-transform: uppercase;
-}
-
-/* ── Two-column grid: vendors + activity ───────────────── */
-.portfolio-grid {
- display: grid;
- grid-template-columns: 1fr 340px;
- gap: 22px;
-}
-
-/* ── Vendor cards ─────────────────────────────────────── */
-.vendors {
- display: grid;
- grid-template-columns: repeat(2, 1fr);
- gap: 12px;
-}
-.vendor {
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius-lg);
- padding: 14px;
- position: relative;
- overflow: hidden;
- cursor: pointer;
- transition: transform var(--dur-base) var(--ease-spring), border-color var(--dur-base) var(--ease);
-}
-.vendor:hover { transform: translateY(-2px); border-color: var(--border-strong); }
-.vendor::before {
- content: ""; position: absolute;
- top: 0; right: 0; width: 60%; height: 60%;
- background: radial-gradient(circle at top right, var(--vendor-glow) 0%, transparent 70%);
- opacity: 0.50; pointer-events: none;
-}
-.vendor.p1 { --vendor-glow: var(--strawberry-glow); }
-.vendor.p2 { --vendor-glow: var(--tangerine-glow); }
-.vendor.routed { --vendor-glow: var(--bondi-glow); }
-.vendor.healthy { --vendor-glow: var(--lime-glow); }
-.vendor-head {
- display: flex; align-items: flex-start; justify-content: space-between;
- margin-bottom: 10px;
- position: relative;
-}
-.vendor-id { display: flex; align-items: center; gap: 11px; }
-.vendor-logo {
- width: 36px; height: 36px;
- border-radius: var(--radius-md);
- background: var(--surface-2);
- border: 1px solid var(--border);
- display: flex; align-items: center; justify-content: center;
- font-family: var(--font-display);
- font-size: 14px; font-weight: 600; color: var(--text);
- flex-shrink: 0;
-}
-.vendor-name {
- font-family: var(--font-display);
- font-size: 15.5px; font-weight: 600;
- color: var(--text-strong);
- letter-spacing: -0.015em;
- line-height: 1.2;
-}
-.vendor-meta {
- font-family: var(--font-mono); font-size: 10px;
- color: var(--muted); letter-spacing: 0.06em;
- margin-top: 2px;
-}
-.sev {
- font-family: var(--font-mono);
- font-size: 10px; font-weight: 600;
- letter-spacing: 0.10em;
- padding: 3px 8px;
- border-radius: var(--radius-sm);
- border: 1px solid transparent;
-}
-.sev.p1 { color: var(--strawberry); background: var(--strawberry-soft); border-color: rgba(244,102,136,0.20); box-shadow: 0 0 16px rgba(244,102,136,0.15); }
-.sev.p2 { color: var(--tangerine); background: var(--tangerine-soft); border-color: rgba(255,149,64,0.20); box-shadow: 0 0 16px rgba(255,149,64,0.12); }
-.sev.healthy { color: var(--lime); background: var(--lime-soft); border-color: rgba(181,220,85,0.20); }
-.sev.routed { color: var(--bondi); background: var(--bondi-soft); border-color: rgba(43,202,232,0.20); box-shadow: 0 0 16px rgba(43,202,232,0.18); }
-
-.vendor-row {
- display: flex; justify-content: space-between; align-items: center;
- font-size: 12px;
- padding: 5px 0;
- border-top: 1px solid var(--hairline);
- position: relative;
-}
-.vendor-row .lbl { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); }
-.vendor-row .val { font-family: var(--font-mono); color: var(--text); font-feature-settings: "tnum" 1; }
-.vendor-row .val.warn { color: var(--tangerine); }
-.vendor-row .val.crit { color: var(--strawberry); }
-.vendor-row .val.live { color: var(--bondi); }
-.vendor-tags {
- display: flex; gap: 4px; margin-top: 10px;
- padding-top: 10px;
- border-top: 1px solid var(--hairline);
- position: relative;
- flex-wrap: wrap; align-items: center;
-}
-.chip {
- display: inline-flex; align-items: center; gap: 4px;
- padding: 2px 8px;
- border-radius: var(--radius-sm);
- font-family: var(--font-mono);
- font-size: 9.5px; letter-spacing: 0.08em; font-weight: 500;
- text-transform: uppercase;
-}
-.chip.pii { background: var(--grape-soft); color: var(--grape); }
-.chip.phi { background: var(--strawberry-soft); color: var(--strawberry); }
-.chip.payments { background: var(--aqua-soft); color: var(--aqua); }
-.chip.source { background: var(--tangerine-soft); color: var(--tangerine); }
-.owner-av {
- width: 20px; height: 20px; border-radius: 50%;
- display: inline-flex; align-items: center; justify-content: center;
- font-family: var(--font-mono); font-size: 9px; font-weight: 600; color: white;
- margin-left: auto;
-}
-.owner-av.a { background: linear-gradient(135deg, var(--aqua), var(--bondi)); }
-.owner-av.b { background: linear-gradient(135deg, var(--grape), var(--strawberry)); }
-.owner-av.c { background: linear-gradient(135deg, var(--tangerine), var(--strawberry)); }
-.owner-av.d { background: linear-gradient(135deg, var(--lime), var(--bondi)); }
-
-/* ── Activity feed ────────────────────────────────────── */
-.activity {
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius-xl);
- overflow: hidden;
- align-self: start;
-}
-.activity-head {
- padding: 14px 16px;
- border-bottom: 1px solid var(--border);
- display: flex; justify-content: space-between; align-items: center;
-}
-.activity-head .title {
- font-family: var(--font-display);
- font-size: 14px; font-weight: 600;
- color: var(--text-strong); letter-spacing: -0.01em;
-}
-.activity-head .filter {
- font-family: var(--font-mono);
- font-size: 10px; color: var(--muted);
- letter-spacing: 0.10em;
- text-transform: uppercase;
-}
-.activity-item {
- padding: 12px 16px;
- border-bottom: 1px solid var(--hairline);
- cursor: pointer;
- transition: background var(--dur-fast) var(--ease);
-}
-.activity-item:hover { background: var(--surface-2); }
-.activity-item:last-child { border-bottom: 0; }
-.activity-row { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
-.activity-sev {
- font-family: var(--font-mono);
- font-size: 9px; font-weight: 600;
- letter-spacing: 0.10em;
- padding: 2px 6px;
- border-radius: 3px;
- flex-shrink: 0;
-}
-.activity-sev.p1 { color: var(--strawberry); background: var(--strawberry-soft); }
-.activity-sev.p2 { color: var(--tangerine); background: var(--tangerine-soft); }
-.activity-sev.p3 { color: var(--lime); background: var(--lime-soft); }
-.activity-sev.routed { color: var(--bondi); background: var(--bondi-soft); }
-.activity-vendor { font-size: 12.5px; font-weight: 500; color: var(--text-strong); }
-.activity-when { font-family: var(--font-mono); font-size: 10px; color: var(--muted); margin-left: auto; }
-.activity-title { font-size: 12px; color: var(--text-2); line-height: 1.4; margin-bottom: 4px; }
-.activity-meta {
- display: flex; gap: 8px; align-items: center;
- font-family: var(--font-mono); font-size: 10px;
- color: var(--muted); letter-spacing: 0.04em;
-}
-.activity-meta .impact.in { color: var(--strawberry); }
-.activity-meta .impact.out { color: var(--lime); }
diff --git a/apps/web/public/app/app.jsx b/apps/web/public/app/app.jsx
deleted file mode 100644
index c50fef4..0000000
--- a/apps/web/public/app/app.jsx
+++ /dev/null
@@ -1,207 +0,0 @@
-// app.jsx — top-level Unsyphn app: state, navigation, screen mounting
-
-function App() {
- // ── state ────────────────────────────────────────────
- const [state, setState] = React.useState({
- screen: "portfolio", // "portfolio" | "change" | "evidence" | "onboarding"
- escalateOpen: false,
- routing: false, // overlay routing transition
- notion: "P1", // "P1" | "ROUTED"
- routeTime: null,
- evidenceCount: 432,
- activeVendor: "notion", // which vendor the change/evidence screens render
- onboardingTier: "24h",
- onboardedToast: null, // { name, tierLabel } | null
- });
-
- // ── dispatcher ───────────────────────────────────────
- function dispatch(action) {
- switch (action.type) {
- case "goto":
- setState((s) => ({ ...s, screen: action.screen }));
- setTimeout(() => {
- const m = document.querySelector(".app-shell .main");
- if (m) m.scrollTo(0, 0);
- }, 0);
- return;
- case "open-vendor":
- setState((s) => ({ ...s, activeVendor: action.vendor, screen: "change" }));
- setTimeout(() => {
- const m = document.querySelector(".app-shell .main");
- if (m) m.scrollTo(0, 0);
- }, 0);
- return;
- case "open-vendor-bundle":
- setState((s) => ({ ...s, activeVendor: action.vendor, screen: "evidence" }));
- setTimeout(() => {
- const m = document.querySelector(".app-shell .main");
- if (m) m.scrollTo(0, 0);
- }, 0);
- return;
- case "open-escalate":
- setState((s) => ({ ...s, escalateOpen: true }));
- return;
- case "close-escalate":
- setState((s) => ({ ...s, escalateOpen: false }));
- return;
- case "confirm-escalate":
- setState((s) => ({ ...s, escalateOpen: false, routing: true }));
- return;
- case "finish-routing":
- setState((s) => ({
- ...s,
- routing: false,
- notion: "ROUTED",
- routeTime: "14:59:18 EST",
- evidenceCount: 433,
- screen: "evidence",
- activeVendor: "notion",
- }));
- setTimeout(() => {
- const m = document.querySelector(".app-shell .main");
- if (m) m.scrollTo(0, 0);
- }, 0);
- return;
- case "reset-flow":
- setState({
- screen: "portfolio",
- escalateOpen: false,
- routing: false,
- notion: "P1",
- routeTime: null,
- evidenceCount: 432,
- activeVendor: "notion",
- onboardingTier: "24h",
- onboardedToast: null,
- });
- return;
- case "vendor-onboarded":
- setState((s) => ({
- ...s,
- screen: "portfolio",
- onboardingTier: action.tier,
- onboardedToast: {
- name: action.name,
- tierLabel: action.tierLabel,
- vendorId: action.vendorId,
- firstScanRunId: action.firstScanRunId,
- },
- }));
- setTimeout(() => setState((s) => ({ ...s, onboardedToast: null })), 5500);
- setTimeout(() => {
- const m = document.querySelector(".app-shell .main");
- if (m) m.scrollTo(0, 0);
- }, 0);
- return;
- case "acknowledge":
- // state.notion is constrained to "P1" | "ROUTED" — don't widen it here.
- // Acknowledge is a demo no-op until the lifecycle state machine is wired.
- window.alert("Acknowledged. Lifecycle hand-off coming soon.");
- return;
- case "snooze":
- window.alert("Snoozed for 48h. Lifecycle hand-off coming soon.");
- return;
- case "open-copilot":
- window.alert("Copilot coming soon");
- return;
- case "export-diff": {
- const DATA = window.VENDOR_DATA || {};
- const vendor = DATA[state.activeVendor] || {};
- const body = JSON.stringify({ vendor: state.activeVendor, diffs: (vendor.cr || {}).diffs || [] }, null, 2);
- const url = URL.createObjectURL(new Blob([body], { type: "application/json" }));
- const a = document.createElement("a");
- a.href = url;
- a.download = (state.activeVendor || "vendor") + "-diff.json";
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- setTimeout(() => URL.revokeObjectURL(url), 1000);
- return;
- }
- case "assign":
- window.alert("Owner assignment coming soon");
- return;
- case "download-bundle":
- window.print();
- return;
- case "share-audit":
- if (navigator.clipboard && navigator.clipboard.writeText) {
- navigator.clipboard.writeText(location.href).then(
- function () { window.alert("Audit link copied"); },
- function () { window.prompt("Copy the audit link:", location.href); }
- );
- } else {
- window.prompt("Copy the audit link:", location.href);
- }
- return;
- default:
- globalThis["console"].warn("unknown action", action);
- }
- }
-
- // ── browser tabs ─────────────────────────────────────
- const activeRec = (window.VENDOR_DATA || {})[state.activeVendor] || {};
- const vendorSlug = (activeRec.name || "vendor").toLowerCase();
- const bundleSlug = ((activeRec.cr && activeRec.cr.bundleId) || "").replace("·", "-");
- const url = state.screen === "portfolio"
- ? "app.unsyphn.com/portfolio"
- : state.screen === "change"
- ? `app.unsyphn.com/${vendorSlug}/${bundleSlug || "cr"}`
- : state.screen === "onboarding"
- ? "app.unsyphn.com/onboard"
- : `app.unsyphn.com/bundle/${bundleSlug || "cr"}`;
-
- const secondTabTitle = state.screen === "portfolio"
- ? "Notion · ToS"
- : state.screen === "onboarding"
- ? "Onboard Vendor"
- : `${activeRec.name || "Vendor"} · ${state.screen === "evidence" ? "Bundle" : "Change"}`;
-
- const tabs = [
- { title: "Unsyphn · Portfolio" },
- { title: secondTabTitle },
- { title: "DPA · Acme ⟷ Notion" },
- ];
-
- return (
-
-
-
-
- {state.screen === "portfolio" && }
- {state.screen === "change" && }
- {state.screen === "evidence" && }
- {state.screen === "onboarding" && }
-
- {state.escalateOpen && }
- {state.routing && }
-
-
-
- {/* Reset flow pill — anchor outside the chrome */}
-
-
- );
-}
-
-ReactDOM.createRoot(document.getElementById("root")).render(
);
diff --git a/apps/web/public/app/app2.css b/apps/web/public/app/app2.css
deleted file mode 100644
index 6363ea0..0000000
--- a/apps/web/public/app/app2.css
+++ /dev/null
@@ -1,1443 +0,0 @@
-/* =============================================================
- UNSYPHN APP · screen2.css
- ChangeReport · Escalate modal · Routing · Evidence bundle
- ============================================================= */
-
-/* ── ChangeReport screen ──────────────────────────────── */
-.cr-shell { max-width: 1180px; margin: 0 auto; }
-
-.crumbs-bar {
- display: flex; justify-content: space-between; align-items: center;
- margin-bottom: 16px;
- padding: 8px 14px;
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius-full);
-}
-.crumbs {
- display: flex; align-items: center; gap: 8px;
- font-family: var(--font-mono);
- font-size: 11px; letter-spacing: 0.12em;
- text-transform: uppercase;
- color: var(--muted);
-}
-.crumbs .seg { color: var(--text-2); cursor: pointer; }
-.crumbs .seg:hover { color: var(--text); }
-.crumbs .sep { color: var(--muted); opacity: 0.6; }
-.crumbs .current { color: var(--text-strong); }
-.crumbs-bar .icon-btn-row { display: flex; gap: 6px; }
-.icon-btn {
- width: 28px; height: 28px;
- border-radius: 50%;
- background: var(--surface-2);
- border: 1px solid var(--border);
- color: var(--text-2);
- cursor: pointer;
- display: inline-flex; align-items: center; justify-content: center;
- font-size: 13px;
- transition: all var(--dur-fast) var(--ease);
-}
-.icon-btn:hover { background: var(--surface-3); color: var(--text); border-color: var(--border-strong); }
-
-/* Vendor strip (header) */
-.vendor-strip {
- display: flex; align-items: center; gap: 16px;
- padding: 16px;
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius-lg);
- margin-bottom: 18px;
- position: relative;
- overflow: hidden;
-}
-.vendor-strip::before {
- content: ""; position: absolute;
- top: 0; right: 0; width: 40%; height: 100%;
- background: radial-gradient(ellipse at right, var(--strawberry-glow) 0%, transparent 70%);
- opacity: 0.30; pointer-events: none;
-}
-.vendor-strip.routed::before {
- background: radial-gradient(ellipse at right, var(--bondi-glow) 0%, transparent 70%);
- opacity: 0.30;
-}
-.vendor-logo-lg {
- width: 56px; height: 56px;
- border-radius: var(--radius-md);
- background: var(--surface-2);
- border: 1px solid var(--border);
- display: flex; align-items: center; justify-content: center;
- font-family: var(--font-display);
- font-size: 22px; font-weight: 600; color: var(--text);
- flex-shrink: 0;
-}
-.vendor-id-block { position: relative; flex: 1; min-width: 0; }
-.vendor-name-lg {
- font-family: var(--font-display);
- font-size: 22px; font-weight: 600;
- color: var(--text-strong);
- letter-spacing: -0.02em;
- margin-bottom: 5px;
-}
-.vendor-meta-row {
- display: flex; gap: 16px;
- font-family: var(--font-mono);
- font-size: 10.5px; color: var(--muted);
- letter-spacing: 0.10em;
- text-transform: uppercase;
- flex-wrap: wrap;
-}
-.vendor-meta-row strong { color: var(--text); font-weight: 500; }
-.vendor-renew { text-align: right; position: relative; }
-.vendor-renew .num {
- font-family: var(--font-display);
- font-size: 30px; font-weight: 500;
- color: var(--tangerine);
- line-height: 1;
- text-shadow: 0 0 24px var(--tangerine-glow);
- font-feature-settings: "tnum" 1;
- letter-spacing: -0.02em;
-}
-.vendor-renew .num small { color: var(--muted); font-size: 14px; font-weight: 400; }
-.vendor-renew .lbl {
- font-family: var(--font-mono);
- font-size: 10px; color: var(--muted);
- letter-spacing: 0.12em;
- text-transform: uppercase;
- margin-top: 4px;
-}
-
-/* CR layout */
-.cr-layout {
- display: grid;
- grid-template-columns: 1fr 320px;
- gap: 18px;
- padding-bottom: 76px;
-}
-
-.report {
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius-xl);
- padding: 28px 30px 26px;
- box-shadow: 0 16px 48px rgba(0,0,0,0.30);
- position: relative;
- overflow: hidden;
-}
-.report::before {
- content: ""; position: absolute;
- top: -50%; left: -30%; width: 80%; height: 200%;
- background: radial-gradient(ellipse, rgba(244,102,136,0.12) 0%, transparent 60%);
- pointer-events: none;
-}
-.report > * { position: relative; }
-
-.report-head { display: flex; gap: 8px; margin-bottom: 14px; align-items: center; }
-.sev-badge {
- font-family: var(--font-mono);
- font-size: 10.5px; font-weight: 600;
- letter-spacing: 0.14em;
- padding: 5px 10px;
- border-radius: var(--radius-sm);
- border: 1px solid;
-}
-.sev-badge.p1 {
- color: var(--strawberry); background: var(--strawberry-soft);
- border-color: rgba(244,102,136,0.30);
- box-shadow: 0 0 24px rgba(244,102,136,0.20);
-}
-.sev-badge.routed {
- color: var(--bondi); background: var(--bondi-soft);
- border-color: rgba(43,202,232,0.30);
- box-shadow: 0 0 24px rgba(43,202,232,0.20);
-}
-.cat-chip {
- font-family: var(--font-mono);
- font-size: 10px; letter-spacing: 0.10em;
- text-transform: uppercase;
- padding: 5px 9px;
- border-radius: var(--radius-sm);
- background: var(--surface-2); color: var(--text-2);
- border: 1px solid var(--border);
-}
-.cat-chip.data { color: var(--grape); background: var(--grape-soft); border-color: rgba(160,151,216,0.20); }
-.cat-chip.pricing { color: var(--tangerine); background: var(--tangerine-soft); border-color: rgba(255,149,64,0.20); }
-
-.report-title {
- font-family: var(--font-display);
- font-size: 30px; font-weight: 500;
- color: var(--text-strong);
- letter-spacing: -0.028em;
- line-height: 1.15;
- margin: 14px 0;
- max-width: 28ch;
- font-variation-settings: "opsz" 32;
-}
-.report-title em {
- font-family: var(--serif);
- font-style: italic; font-weight: 400;
- color: var(--strawberry);
-}
-.report-title.routed em { color: var(--bondi); }
-.report-detected {
- font-family: var(--font-mono);
- font-size: 11px; color: var(--muted);
- letter-spacing: 0.10em;
- text-transform: uppercase;
- margin-bottom: 22px;
-}
-.report-detected strong { color: var(--text); font-weight: 500; }
-
-/* Impact strip */
-.impact-strip {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- gap: 10px;
- margin-bottom: 22px;
-}
-.impact-cell {
- padding: 12px 14px;
- border-radius: var(--radius-md);
- background: var(--surface-2);
- border: 1px solid var(--border);
-}
-.impact-cell .lbl {
- font-family: var(--font-mono);
- font-size: 10px; color: var(--muted);
- letter-spacing: 0.12em;
- text-transform: uppercase;
- margin-bottom: 5px;
-}
-.impact-cell .val {
- font-family: var(--font-mono);
- font-size: 19px; font-weight: 500;
- color: var(--text-strong);
- letter-spacing: -0.015em;
- line-height: 1;
- font-feature-settings: "tnum" 1;
-}
-.impact-cell.dollar .val { color: var(--strawberry); text-shadow: 0 0 16px var(--strawberry-glow); }
-.impact-cell.delta .val { color: var(--tangerine); text-shadow: 0 0 16px var(--tangerine-glow); }
-.impact-cell.compl .val { color: var(--grape); text-shadow: 0 0 16px var(--grape-glow); font-size: 15px; }
-
-/* Diff blocks */
-.diff-block { margin-bottom: 16px; }
-.diff-label {
- font-family: var(--font-mono);
- font-size: 10px; color: var(--muted);
- letter-spacing: 0.12em;
- text-transform: uppercase;
- margin-bottom: 10px;
-}
-.diff-pair {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 10px;
-}
-.clause {
- padding: 14px;
- border-radius: var(--radius-md);
- border: 1px solid;
- position: relative;
-}
-.clause-head {
- display: flex; justify-content: space-between; align-items: center;
- margin-bottom: 8px;
-}
-.clause-lbl {
- font-family: var(--font-mono);
- font-size: 10px; font-weight: 600;
- letter-spacing: 0.10em;
- padding: 3px 8px;
- border-radius: 3px;
-}
-.clause-when {
- font-family: var(--font-mono);
- font-size: 9.5px; color: var(--muted);
- letter-spacing: 0.06em;
-}
-.clause-text {
- font-family: var(--serif);
- font-size: 14px; line-height: 1.5;
- color: var(--text);
- font-style: italic;
- margin: 0;
-}
-.clause-text strong {
- font-style: normal;
- padding: 1px 5px;
- border-radius: 3px;
-}
-.clause-source {
- font-family: var(--font-mono);
- font-size: 9.5px; color: var(--muted);
- margin-top: 10px;
- padding-top: 10px;
- border-top: 1px solid var(--hairline);
- letter-spacing: 0.06em;
- word-break: break-all;
-}
-.clause-source a { color: var(--bondi); }
-.clause.before {
- background: var(--strawberry-soft);
- border-color: rgba(244,102,136,0.20);
-}
-.clause.before .clause-lbl { color: var(--strawberry); background: rgba(244,102,136,0.15); }
-.clause.before .clause-text strong { background: rgba(244,102,136,0.20); color: var(--strawberry); }
-.clause.after {
- background: var(--lime-soft);
- border-color: rgba(181,220,85,0.20);
-}
-.clause.after .clause-lbl { color: var(--lime); background: rgba(181,220,85,0.15); }
-.clause.after .clause-text strong { background: rgba(181,220,85,0.20); color: var(--lime); }
-
-/* Recommendation */
-.recommendation {
- margin-top: 16px;
- padding: 14px 16px;
- border-radius: var(--radius-md);
- background: var(--surface-2);
- border: 1px solid var(--border);
-}
-.reco-head {
- display: flex; align-items: center; gap: 8px;
- font-family: var(--font-mono);
- font-size: 10px; letter-spacing: 0.14em;
- text-transform: uppercase;
- color: var(--bondi);
- margin-bottom: 8px;
-}
-.reco-head::before {
- content: ""; width: 6px; height: 6px; border-radius: 50%;
- background: var(--bondi); box-shadow: 0 0 12px var(--bondi);
-}
-.reco-text {
- font-size: 13.5px; color: var(--text); line-height: 1.55;
- margin: 0;
-}
-.reco-text em {
- font-family: var(--serif);
- font-style: italic; color: var(--bondi);
-}
-
-/* Side panels */
-.cr-side { display: flex; flex-direction: column; gap: 12px; }
-.panel {
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius-lg);
- padding: 14px;
- position: relative;
- overflow: hidden;
-}
-.panel::before {
- content: ""; position: absolute;
- top: -50%; right: -20%; width: 70%; height: 100%;
- background: radial-gradient(circle, var(--panel-glow) 0%, transparent 60%);
- opacity: 0.40; pointer-events: none;
-}
-.panel.policy { --panel-glow: var(--strawberry-glow); }
-.panel.routing { --panel-glow: var(--bondi-glow); }
-.panel.evidence { --panel-glow: var(--grape-glow); }
-.panel > * { position: relative; }
-
-.panel-head {
- display: flex; align-items: center; gap: 8px;
- font-family: var(--font-mono);
- font-size: 10px; letter-spacing: 0.14em;
- text-transform: uppercase;
- color: var(--muted);
- margin-bottom: 12px;
-}
-.panel-head .dot { width: 6px; height: 6px; border-radius: 50%; }
-.panel-head.strawberry .dot { background: var(--strawberry); box-shadow: 0 0 8px var(--strawberry); } .panel-head.strawberry { color: var(--strawberry); }
-.panel-head.bondi .dot { background: var(--bondi); box-shadow: 0 0 8px var(--bondi); } .panel-head.bondi { color: var(--bondi); }
-.panel-head.grape .dot { background: var(--grape); box-shadow: 0 0 8px var(--grape); } .panel-head.grape { color: var(--grape); }
-.panel-head.lime .dot { background: var(--lime); box-shadow: 0 0 8px var(--lime); } .panel-head.lime { color: var(--lime); }
-
-.policy-name {
- font-size: 12.5px; font-weight: 500;
- color: var(--text);
- margin-bottom: 10px;
-}
-.policy-yaml {
- background: var(--bg);
- border: 1px solid var(--border);
- border-radius: var(--radius-sm);
- padding: 10px 12px;
- font-family: var(--font-mono);
- font-size: 10.5px;
- line-height: 1.55;
- color: var(--text);
- overflow-x: auto;
- margin: 0 0 8px;
- white-space: pre;
-}
-.y-key { color: var(--bondi); }
-.y-str { color: var(--lime); }
-.y-com { color: var(--muted); font-style: italic; }
-.policy-meta {
- font-family: var(--font-mono);
- font-size: 9.5px; color: var(--muted);
- letter-spacing: 0.06em;
-}
-
-/* Routed actions */
-.action-item {
- display: flex; align-items: center; gap: 10px;
- padding: 8px 0;
- border-bottom: 1px solid var(--hairline);
- font-size: 12px;
-}
-.action-item:last-child { border-bottom: 0; }
-.action-icon {
- width: 24px; height: 24px;
- border-radius: 6px;
- background: var(--surface-2);
- border: 1px solid var(--border);
- display: flex; align-items: center; justify-content: center;
- font-family: var(--font-mono);
- font-size: 9.5px; font-weight: 600;
- color: var(--text-2);
- flex-shrink: 0;
-}
-.action-icon.slack { color: var(--strawberry); background: var(--strawberry-soft); border-color: rgba(244,102,136,0.20); }
-.action-icon.jira { color: var(--aqua); background: var(--aqua-soft); border-color: rgba(91,160,240,0.20); }
-.action-icon.cal { color: var(--bondi); background: var(--bondi-soft); border-color: rgba(43,202,232,0.20); }
-.action-icon.email { color: var(--grape); background: var(--grape-soft); border-color: rgba(160,151,216,0.20); }
-.action-detail { flex: 1; min-width: 0; }
-.action-target { color: var(--text); font-weight: 500; }
-.action-when { font-family: var(--font-mono); font-size: 9.5px; color: var(--muted); margin-top: 1px; letter-spacing: 0.06em; }
-.action-status {
- font-family: var(--font-mono);
- font-size: 9px; font-weight: 600;
- letter-spacing: 0.10em;
- padding: 2px 6px;
- border-radius: 3px;
- color: var(--lime); background: var(--lime-soft);
-}
-.action-status.pending { color: var(--tangerine); background: var(--tangerine-soft); }
-
-/* Evidence preview mini */
-.ev-preview {
- aspect-ratio: 1 / 1.25;
- border-radius: var(--radius-md);
- background: var(--surface-2);
- border: 1px solid var(--border);
- padding: 12px;
- margin-bottom: 10px;
- position: relative;
- overflow: hidden;
- cursor: pointer;
-}
-.ev-page-lines { height: 5px; background: var(--hairline); border-radius: 3px; margin-bottom: 6px; }
-.ev-page-lines.short { width: 60%; }
-.ev-page-lines.med { width: 80%; }
-.ev-page-lines.dark { background: rgba(255,255,255,0.12); height: 7px; margin-bottom: 9px; width: 70%; }
-.ev-snippet {
- margin-top: 10px;
- padding: 6px 8px;
- border-left: 2px solid var(--strawberry);
- background: var(--strawberry-soft);
- border-radius: 3px;
-}
-.ev-snippet .line { height: 3px; background: rgba(244,102,136,0.40); border-radius: 2px; margin-bottom: 3px; }
-.ev-snippet .line.short { width: 50%; }
-.citation-row {
- display: flex; align-items: center; gap: 6px;
- margin-top: 8px;
- font-family: var(--font-mono);
- font-size: 9.5px; color: var(--muted);
- letter-spacing: 0.06em;
-}
-.cit-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--lime); box-shadow: 0 0 8px var(--lime); }
-
-/* Actions bar */
-.actions-bar {
- position: absolute;
- left: 256px; right: 36px; bottom: 24px;
- padding: 11px 16px;
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius-full);
- display: flex; gap: 8px; align-items: center; justify-content: space-between;
- box-shadow: 0 16px 48px rgba(0,0,0,0.30);
- z-index: 10;
-}
-.actions-row { display: flex; gap: 8px; flex-wrap: wrap; }
-.btn {
- font-family: var(--font-text);
- font-size: 12.5px; font-weight: 500;
- padding: 0 14px;
- height: 34px;
- border-radius: var(--radius-full);
- border: 1px solid transparent;
- cursor: pointer;
- display: inline-flex; align-items: center; gap: 6px;
- transition: all var(--dur-fast) var(--ease-spring);
- user-select: none;
- font-feature-settings: "tnum" 1;
- letter-spacing: 0.02em;
-}
-.btn:active { transform: scale(0.97); }
-.btn-primary {
- background: var(--lime); color: var(--on-lime);
- box-shadow: 0 0 24px var(--lime-glow), inset 0 1px 0 rgba(255,255,255,0.30);
-}
-.btn-primary:hover { background: #C4E866; }
-.btn-escalate {
- background: var(--strawberry); color: var(--on-strawberry);
- box-shadow: 0 0 24px var(--strawberry-glow), inset 0 1px 0 rgba(255,255,255,0.30);
-}
-.btn-escalate:hover { background: #F87898; }
-.btn-bondi {
- background: var(--bondi); color: var(--on-bondi);
- box-shadow: 0 0 24px var(--bondi-glow), inset 0 1px 0 rgba(255,255,255,0.30);
-}
-.btn-bondi:hover { background: #4DD7F0; }
-.btn-ghost {
- background: var(--surface-2);
- color: var(--text);
- border-color: var(--border-strong);
-}
-.btn-ghost:hover { background: var(--surface-3); }
-
-/* ── Escalate modal ───────────────────────────────────── */
-.modal-scrim {
- position: absolute; inset: 0;
- background: rgba(6, 6, 12, 0.78);
- backdrop-filter: blur(6px);
- display: flex; align-items: center; justify-content: center;
- z-index: 100;
- animation: fade-in 0.20s var(--ease);
-}
-@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
-.modal {
- width: 640px; max-height: 820px;
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius-xl);
- box-shadow: 0 32px 80px rgba(0,0,0,0.50);
- display: flex; flex-direction: column;
- overflow: hidden;
- animation: slide-up 0.30s var(--ease-spring);
- position: relative;
-}
-@keyframes slide-up {
- from { opacity: 0; transform: translateY(20px) scale(0.98); }
- to { opacity: 1; transform: translateY(0) scale(1); }
-}
-.modal::before {
- content: ""; position: absolute;
- top: 0; left: 0; right: 0; height: 80px;
- background: radial-gradient(ellipse at top, rgba(244,102,136,0.18) 0%, transparent 70%);
- pointer-events: none;
-}
-.modal-head {
- padding: 18px 22px 14px;
- border-bottom: 1px solid var(--hairline);
- display: flex; justify-content: space-between; align-items: flex-start;
- position: relative;
-}
-.modal-head .left .eyebrow {
- font-family: var(--font-mono);
- font-size: 10px; color: var(--strawberry);
- letter-spacing: 0.14em; text-transform: uppercase;
- margin-bottom: 6px;
-}
-.modal-head .left .ttl {
- font-family: var(--font-display);
- font-size: 22px; font-weight: 500;
- color: var(--text-strong);
- letter-spacing: -0.02em; line-height: 1.2;
- margin: 0;
-}
-.modal-head .left .ttl em { font-family: var(--serif); font-style: italic; font-weight: 400; color: var(--strawberry); }
-.modal-body {
- padding: 18px 22px;
- overflow-y: auto;
- display: flex; flex-direction: column; gap: 16px;
- position: relative;
-}
-.field {
- display: flex; flex-direction: column; gap: 6px;
-}
-.field-label {
- font-family: var(--font-mono);
- font-size: 10px; color: var(--muted);
- letter-spacing: 0.12em; text-transform: uppercase;
- display: flex; justify-content: space-between; align-items: center;
-}
-.field-label .req { color: var(--strawberry); }
-.field-input {
- background: var(--bg);
- border: 1px solid var(--border);
- border-radius: var(--radius-md);
- padding: 10px 12px;
- font-family: inherit;
- font-size: 13px;
- color: var(--text);
- outline: none;
- resize: vertical;
-}
-.field-input:focus { border-color: var(--bondi); box-shadow: 0 0 0 3px var(--bondi-soft); }
-.field-input.locked {
- background: var(--surface-2);
- color: var(--strawberry);
- font-family: var(--font-mono);
- letter-spacing: 0.10em;
-}
-.recipients {
- display: flex; gap: 8px; flex-wrap: wrap;
-}
-.recipient {
- display: flex; align-items: center; gap: 8px;
- padding: 6px 12px 6px 6px;
- background: var(--surface-2);
- border: 1px solid var(--border);
- border-radius: var(--radius-full);
- font-size: 12.5px;
-}
-.recipient .av {
- width: 22px; height: 22px;
- border-radius: 50%;
- display: flex; align-items: center; justify-content: center;
- font-family: var(--font-mono); font-size: 9px; font-weight: 600;
- color: white;
-}
-.recipient .who { color: var(--text-strong); font-weight: 500; }
-.recipient .role { color: var(--muted); font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.06em; }
-.cite-list {
- display: flex; flex-direction: column; gap: 6px;
- font-family: var(--font-mono);
- font-size: 11px; color: var(--text-2);
-}
-.cite-list .cite {
- display: flex; align-items: center; gap: 8px;
- padding: 6px 10px;
- background: var(--surface-2);
- border: 1px solid var(--border);
- border-radius: var(--radius-md);
-}
-.cite-list .cite .ck {
- width: 14px; height: 14px;
- border-radius: 3px;
- background: var(--lime);
- display: flex; align-items: center; justify-content: center;
- font-size: 10px; color: var(--on-lime); font-weight: 700;
-}
-.cite-list .cite .src { color: var(--text); flex: 1; }
-.cite-list .cite .hash { color: var(--muted); }
-.modal-foot {
- padding: 14px 22px;
- border-top: 1px solid var(--hairline);
- display: flex; justify-content: space-between; align-items: center;
- background: var(--surface-2);
-}
-.modal-foot .left {
- font-family: var(--font-mono); font-size: 10px;
- color: var(--muted); letter-spacing: 0.10em;
- text-transform: uppercase;
-}
-.modal-foot .left b { color: var(--lime); }
-
-/* ── Routing transition ───────────────────────────────── */
-.routing-screen {
- position: absolute; inset: 0;
- display: flex; align-items: center; justify-content: center;
- flex-direction: column; gap: 28px;
- z-index: 120;
- background:
- radial-gradient(ellipse 1200px 800px at 50% 50%, rgba(43,202,232,0.10) 0%, transparent 60%),
- rgba(10, 10, 18, 0.96);
- backdrop-filter: blur(20px);
-}
-.routing-pulse {
- width: 96px; height: 96px;
- border-radius: 50%;
- background: var(--bondi);
- box-shadow: 0 0 64px var(--bondi-glow), 0 0 128px var(--bondi-glow);
- position: relative;
-}
-.routing-pulse::before, .routing-pulse::after {
- content: ""; position: absolute; inset: 0;
- border-radius: 50%;
- border: 2px solid var(--bondi);
- opacity: 0.6;
- animation: ring 1.8s ease-out infinite;
-}
-.routing-pulse::after { animation-delay: 0.9s; }
-@keyframes ring {
- 0% { transform: scale(1); opacity: 0.7; }
- 100% { transform: scale(3); opacity: 0; }
-}
-.routing-title {
- font-family: var(--font-display);
- font-size: 36px; font-weight: 500;
- color: var(--text-strong); letter-spacing: -0.025em;
- text-align: center; max-width: 700px;
- margin: 0;
- font-variation-settings: "opsz" 48;
-}
-.routing-title em { font-family: var(--serif); font-style: italic; font-weight: 400; color: var(--bondi); }
-.routing-log {
- width: 720px;
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius-lg);
- padding: 18px 22px;
- font-family: var(--font-mono);
- font-size: 12.5px;
- line-height: 1.85;
- color: var(--text-2);
- font-feature-settings: "tnum" 1;
- height: 240px;
- overflow: hidden;
- box-shadow: 0 16px 48px rgba(0,0,0,0.30);
-}
-.routing-log .line { display: flex; gap: 14px; }
-.routing-log .ts { color: var(--bondi); letter-spacing: 0.06em; flex-shrink: 0; }
-.routing-log .lvl { width: 56px; flex-shrink: 0; letter-spacing: 0.12em; }
-.routing-log .lvl.ok { color: var(--lime); }
-.routing-log .lvl.info { color: var(--bondi); }
-.routing-log .lvl.exec { color: var(--tangerine); }
-.routing-log .lvl.sign { color: var(--grape); }
-.routing-log .msg { color: var(--text); }
-.routing-log .msg b { color: var(--text-strong); font-weight: 500; }
-.routing-log .new { animation: type-in 0.30s var(--ease); }
-@keyframes type-in {
- from { opacity: 0; transform: translateY(4px); }
- to { opacity: 1; transform: translateY(0); }
-}
-
-/* ── Evidence bundle screen ───────────────────────────── */
-.bundle-shell {
- max-width: 1180px; margin: 0 auto;
-}
-.bundle-strip {
- display: flex; align-items: center; gap: 16px;
- padding: 16px;
- background: var(--surface);
- border: 1px solid var(--lime);
- border-radius: var(--radius-lg);
- margin-bottom: 18px;
- position: relative; overflow: hidden;
- box-shadow: 0 0 32px var(--lime-glow);
-}
-.bundle-strip::before {
- content: ""; position: absolute;
- top: 0; right: 0; width: 40%; height: 100%;
- background: radial-gradient(ellipse at right, var(--lime-glow) 0%, transparent 70%);
- opacity: 0.30; pointer-events: none;
-}
-.bundle-strip .seal-lg {
- width: 64px; height: 64px;
- border-radius: 50%;
- border: 1.5px solid var(--lime);
- display: flex; align-items: center; justify-content: center;
- font-family: var(--serif);
- font-weight: 800; font-size: 24px;
- color: var(--lime);
- background: rgba(20, 20, 30, 0.7);
- flex-shrink: 0;
- box-shadow: 0 0 24px var(--lime-glow);
- position: relative;
-}
-.bundle-strip .seal-lg::after {
- content: ""; position: absolute; inset: 4px;
- border-radius: 50%;
- border: 1px solid var(--lime);
- opacity: 0.5;
-}
-.bundle-strip .id-block { flex: 1; position: relative; }
-.bundle-strip .ttl {
- font-family: var(--font-display);
- font-size: 22px; font-weight: 600;
- color: var(--text-strong);
- letter-spacing: -0.02em;
- margin-bottom: 5px;
-}
-.bundle-strip .meta {
- display: flex; gap: 16px;
- font-family: var(--font-mono);
- font-size: 10.5px; color: var(--muted);
- letter-spacing: 0.10em;
- text-transform: uppercase;
- flex-wrap: wrap;
-}
-.bundle-strip .meta b { color: var(--text); font-weight: 500; }
-.bundle-strip .stamp {
- font-family: var(--font-mono);
- font-size: 10.5px; font-weight: 600;
- letter-spacing: 0.16em; text-transform: uppercase;
- color: var(--lime);
- padding: 6px 14px;
- border: 1.5px solid var(--lime);
- border-radius: var(--radius-sm);
- background: var(--lime-soft);
- position: relative;
-}
-
-.bundle-layout {
- display: grid;
- grid-template-columns: 1fr 320px;
- gap: 18px;
- padding-bottom: 76px;
-}
-
-.bundle-doc {
- background: #14141E;
- border: 1px solid var(--border);
- border-radius: var(--radius-xl);
- padding: 36px 44px;
- position: relative;
- overflow: hidden;
- box-shadow: 0 16px 48px rgba(0,0,0,0.30);
-}
-.bundle-doc::before {
- content: ""; position: absolute;
- inset: 6px; border: 1px solid var(--lime); opacity: 0.20;
- border-radius: calc(var(--radius-xl) - 4px);
- pointer-events: none;
-}
-.bundle-cover {
- text-align: center; padding: 14px 0 26px;
- border-bottom: 1px solid var(--hairline);
- margin-bottom: 22px;
- position: relative;
-}
-.bundle-cover .eb {
- font-family: var(--font-mono); font-size: 11px;
- letter-spacing: 0.32em; text-transform: uppercase;
- color: var(--lime); opacity: 0.85; margin-bottom: 12px;
-}
-.bundle-cover h1 {
- font-family: var(--serif); font-weight: 800;
- font-size: 56px; letter-spacing: -0.020em;
- color: var(--lime); line-height: 0.98;
- text-shadow: 0 0 32px var(--lime-glow);
- margin: 0 0 10px;
-}
-.bundle-cover .sub {
- font-family: var(--font-display); font-size: 20px;
- font-weight: 500; color: var(--text-strong);
- letter-spacing: -0.012em;
- margin: 0 0 6px;
- font-variation-settings: "opsz" 32;
-}
-.bundle-cover .sub em { font-family: var(--serif); font-style: italic; font-weight: 400; color: var(--lime); }
-.bundle-cover .meta-line {
- font-family: var(--font-mono); font-size: 10.5px;
- letter-spacing: 0.14em; text-transform: uppercase;
- color: var(--text-2);
- margin-top: 12px;
- display: flex; gap: 16px; justify-content: center;
-}
-.bundle-cover .meta-line b { color: var(--text); font-weight: 500; }
-
-.bundle-section {
- margin-bottom: 22px;
- position: relative;
-}
-.bundle-section h3 {
- font-family: var(--font-mono);
- font-size: 11px; letter-spacing: 0.16em;
- text-transform: uppercase;
- color: var(--lime);
- margin: 0 0 10px;
- padding-bottom: 6px;
- border-bottom: 1px solid var(--hairline);
- display: flex; align-items: center; gap: 8px;
-}
-.bundle-section h3 .num {
- font-family: var(--font-mono);
- background: var(--lime); color: var(--on-lime);
- width: 20px; height: 20px;
- border-radius: 4px;
- font-size: 11px; font-weight: 700;
- display: inline-flex; align-items: center; justify-content: center;
-}
-.bundle-section .body {
- font-size: 13.5px; color: var(--text-2); line-height: 1.55;
-}
-.bundle-section .body p { margin: 0 0 8px; }
-.bundle-section .body b { color: var(--text); font-weight: 500; }
-
-.mini-diff {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 10px;
- margin: 10px 0;
-}
-.mini-diff .mc {
- padding: 10px 12px;
- border-radius: var(--radius-sm);
- font-family: var(--serif);
- font-size: 12.5px; line-height: 1.5;
- font-style: italic;
- border: 1px solid;
-}
-.mini-diff .mc.b { background: var(--strawberry-soft); border-color: rgba(244,102,136,0.20); color: var(--text); }
-.mini-diff .mc.a { background: var(--lime-soft); border-color: rgba(181,220,85,0.20); color: var(--text); }
-.mini-diff .mc strong { font-style: normal; padding: 1px 4px; border-radius: 2px; }
-.mini-diff .mc.b strong { background: rgba(244,102,136,0.20); color: var(--strawberry); }
-.mini-diff .mc.a strong { background: rgba(181,220,85,0.20); color: var(--lime); }
-.mini-diff .mc .src {
- font-family: var(--font-mono); font-size: 9.5px;
- letter-spacing: 0.06em; color: var(--muted);
- margin-top: 6px; padding-top: 6px;
- border-top: 1px solid var(--hairline);
- font-style: normal;
- word-break: break-all;
-}
-
-.routing-trail {
- font-family: var(--font-mono); font-size: 11.5px;
- color: var(--text-2);
- display: flex; flex-direction: column; gap: 4px;
-}
-.routing-trail .row { display: flex; gap: 14px; padding: 4px 0; border-bottom: 1px solid var(--hairline); }
-.routing-trail .row:last-child { border-bottom: 0; }
-.routing-trail .ts { color: var(--lime); flex-shrink: 0; width: 100px; }
-.routing-trail .msg { color: var(--text); flex: 1; }
-.routing-trail .msg b { color: var(--text-strong); font-weight: 500; }
-.routing-trail .ok { color: var(--lime); }
-
-.signoff {
- margin-top: 26px;
- padding: 20px 22px;
- border: 2px double var(--lime);
- border-radius: 4px;
- display: flex; align-items: center; justify-content: space-between;
- position: relative;
- background: rgba(181,220,85,0.04);
-}
-.signoff .left {
- font-family: var(--serif);
- font-size: 14px; color: var(--text-2);
- font-style: italic;
-}
-.signoff .left b { color: var(--text); font-weight: 500; font-style: normal; }
-.signoff .right {
- font-family: var(--serif); font-weight: 800;
- font-size: 28px; color: var(--lime);
- letter-spacing: -0.014em;
- transform: rotate(-3deg);
- text-shadow: 0 0 24px var(--lime-glow);
-}
-
-/* Bundle side: TOC + actions */
-.toc-card {
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius-lg);
- padding: 14px;
-}
-.toc-card h4 {
- font-family: var(--font-mono);
- font-size: 10px; letter-spacing: 0.14em;
- text-transform: uppercase;
- color: var(--muted);
- margin: 0 0 10px;
-}
-.toc-item {
- display: flex; align-items: center; gap: 10px;
- padding: 8px 0;
- border-bottom: 1px solid var(--hairline);
- font-size: 12.5px;
- cursor: pointer;
-}
-.toc-item:last-child { border-bottom: 0; }
-.toc-item:hover .toc-num { background: var(--lime); color: var(--on-lime); }
-.toc-num {
- width: 22px; height: 22px;
- background: var(--surface-2);
- border: 1px solid var(--border);
- border-radius: 4px;
- font-family: var(--font-mono);
- font-size: 10.5px; font-weight: 600;
- color: var(--text-2);
- display: flex; align-items: center; justify-content: center;
- transition: all var(--dur-fast) var(--ease);
-}
-.toc-item .toc-ttl { flex: 1; color: var(--text); }
-.toc-item .toc-pages { font-family: var(--font-mono); font-size: 9.5px; color: var(--muted); }
-
-/* =============================================================
- ONBOARDING — vendor onboarding screen + tier picker
- ============================================================= */
-
-/* "+ ONBOARD VENDOR" pill button in the Portfolio TopBar */
-.onb-cta-pill {
- display: inline-flex; align-items: center; gap: 7px;
- padding: 6px 13px 6px 11px;
- background: linear-gradient(135deg, rgba(91,160,240,0.20), rgba(43,202,232,0.18));
- border: 1px solid rgba(91,160,240,0.45);
- border-radius: var(--radius-full);
- font-family: var(--font-mono);
- font-size: 10.5px;
- letter-spacing: 0.12em;
- color: var(--text);
- cursor: pointer;
- transition: all var(--dur-fast) var(--ease);
- box-shadow: 0 0 14px rgba(91,160,240,0.18), inset 0 1px 0 rgba(255,255,255,0.08);
-}
-.onb-cta-pill:hover {
- border-color: rgba(91,160,240,0.75);
- background: linear-gradient(135deg, rgba(91,160,240,0.30), rgba(43,202,232,0.28));
- box-shadow: 0 0 22px rgba(91,160,240,0.35), inset 0 1px 0 rgba(255,255,255,0.12);
- transform: translateY(-1px);
-}
-.onb-cta-plus {
- display: inline-flex; align-items: center; justify-content: center;
- width: 14px; height: 14px;
- border-radius: 50%;
- background: var(--aqua);
- color: var(--bg);
- font-family: var(--font-display);
- font-weight: 700;
- font-size: 13px;
- line-height: 1;
-}
-
-/* Onboarding screen header */
-.onb-header {
- display: flex; justify-content: space-between; align-items: flex-start;
- gap: 24px;
- margin: 16px 0 24px;
-}
-.onb-header-left { max-width: 640px; }
-.onb-title { margin: 8px 0 12px; }
-.onb-title em { color: var(--bondi); }
-.onb-sub { color: var(--text-2); margin: 0; }
-.btn-back-pill {
- flex-shrink: 0;
- padding: 8px 14px;
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius-full);
- font-family: var(--font-mono);
- font-size: 11px;
- letter-spacing: 0.10em;
- text-transform: uppercase;
- color: var(--text-2);
- cursor: pointer;
- transition: all var(--dur-fast) var(--ease);
-}
-.btn-back-pill:hover { color: var(--text); border-color: var(--border-strong); background: var(--surface-2); }
-
-/* Name + URL field row */
-.onb-fields {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 12px;
- margin-bottom: 24px;
-}
-.onb-field { display: flex; flex-direction: column; gap: 6px; }
-.onb-field-label {
- font-family: var(--font-mono);
- font-size: 10px;
- letter-spacing: 0.12em;
- color: var(--muted);
-}
-.onb-input {
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius-md);
- padding: 11px 14px;
- font-family: var(--font-text);
- font-size: 14px;
- color: var(--text);
- transition: all var(--dur-fast) var(--ease);
-}
-.onb-input::placeholder { color: var(--muted); }
-.onb-input:focus {
- outline: none;
- border-color: var(--aqua);
- background: var(--surface-2);
- box-shadow: 0 0 0 3px rgba(91,160,240,0.12);
-}
-
-/* Tier grid */
-.tier-grid {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- gap: 16px;
- margin-bottom: 24px;
-}
-
-.tier-card {
- position: relative;
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius-lg);
- padding: 20px;
- display: flex;
- flex-direction: column;
- cursor: pointer;
- transition: all var(--dur-fast) var(--ease);
- overflow: hidden;
-}
-.tier-card::before {
- content: "";
- position: absolute;
- inset: 0;
- border-radius: var(--radius-lg);
- pointer-events: none;
- opacity: 0;
- transition: opacity var(--dur-fast) var(--ease);
-}
-.tier-card.strawberry::before { background: radial-gradient(ellipse 240px 180px at 100% 0%, var(--strawberry-soft) 0%, transparent 60%); }
-.tier-card.bondi::before { background: radial-gradient(ellipse 240px 180px at 100% 0%, var(--bondi-soft) 0%, transparent 60%); }
-.tier-card.lime::before { background: radial-gradient(ellipse 240px 180px at 100% 0%, var(--lime-soft) 0%, transparent 60%); }
-.tier-card::before { opacity: 1; }
-
-.tier-card:hover {
- border-color: var(--border-strong);
- transform: translateY(-2px);
- box-shadow: var(--shadow-card);
-}
-
-.tier-card.is-recommended {
- border-color: rgba(43,202,232,0.45);
- box-shadow: 0 0 0 1px rgba(43,202,232,0.18), 0 8px 32px rgba(0,0,0,0.20);
-}
-
-.tier-card.is-selected.strawberry {
- border-color: var(--strawberry);
- box-shadow: 0 0 0 1px var(--strawberry), 0 0 24px var(--strawberry-glow);
-}
-.tier-card.is-selected.bondi {
- border-color: var(--bondi);
- box-shadow: 0 0 0 1px var(--bondi), 0 0 24px var(--bondi-glow);
-}
-.tier-card.is-selected.lime {
- border-color: var(--lime);
- box-shadow: 0 0 0 1px var(--lime), 0 0 24px var(--lime-glow);
-}
-
-.tier-badge {
- position: absolute;
- top: 12px; right: 12px;
- z-index: 2;
- background: var(--bondi);
- color: var(--on-bondi);
- font-family: var(--font-mono);
- font-size: 9.5px;
- font-weight: 600;
- letter-spacing: 0.14em;
- padding: 3px 8px;
- border-radius: var(--radius-full);
- box-shadow: 0 0 14px var(--bondi-glow);
-}
-
-.tier-head {
- position: relative; z-index: 1;
- display: flex; align-items: baseline; gap: 10px;
- margin-bottom: 12px;
-}
-.tier-sla {
- font-family: var(--font-display);
- font-size: 38px;
- font-weight: 700;
- letter-spacing: -0.04em;
- line-height: 1;
- color: var(--text-strong);
- font-variation-settings: "opsz" 48;
-}
-.tier-card.strawberry .tier-sla { color: var(--strawberry); }
-.tier-card.bondi .tier-sla { color: var(--bondi); }
-.tier-card.lime .tier-sla { color: var(--lime); }
-
-.tier-tagline {
- font-family: var(--font-mono);
- font-size: 10.5px;
- letter-spacing: 0.16em;
- text-transform: uppercase;
- color: var(--muted);
- padding-bottom: 4px;
-}
-
-.tier-name {
- position: relative; z-index: 1;
- margin: 0 0 6px;
-}
-
-.tier-desc {
- position: relative; z-index: 1;
- font-size: 13px;
- color: var(--text-2);
- margin: 0 0 16px;
- line-height: 1.5;
-}
-
-.tier-price-row {
- position: relative; z-index: 1;
- display: flex; align-items: baseline; gap: 6px;
- padding: 12px 0;
- border-top: 1px solid var(--hairline);
- border-bottom: 1px solid var(--hairline);
- margin-bottom: 14px;
-}
-.tier-price {
- font-family: var(--font-display);
- font-size: 28px;
- font-weight: 600;
- letter-spacing: -0.02em;
- color: var(--text-strong);
- font-feature-settings: "tnum" 1;
-}
-.tier-unit {
- font-family: var(--font-mono);
- font-size: 10.5px;
- color: var(--muted);
- letter-spacing: 0.06em;
-}
-
-.tier-features {
- position: relative; z-index: 1;
- list-style: none;
- padding: 0;
- margin: 0 0 18px;
- flex: 1;
-}
-.tier-features li {
- display: flex; align-items: flex-start; gap: 8px;
- padding: 5px 0;
- font-size: 12.5px;
- color: var(--text);
- line-height: 1.4;
-}
-.tier-check {
- flex-shrink: 0;
- width: 14px;
- font-family: var(--font-mono);
- font-weight: 600;
- font-size: 11px;
-}
-.tier-card.strawberry .tier-check { color: var(--strawberry); }
-.tier-card.bondi .tier-check { color: var(--bondi); }
-.tier-card.lime .tier-check { color: var(--lime); }
-
-.tier-foot {
- position: relative; z-index: 1;
- display: flex; justify-content: space-between; align-items: center;
-}
-.tier-eta {
- font-family: var(--font-mono);
- font-size: 9.5px;
- letter-spacing: 0.14em;
- color: var(--muted);
-}
-.tier-select-pill {
- font-family: var(--font-mono);
- font-size: 10px;
- letter-spacing: 0.14em;
- padding: 5px 10px;
- border-radius: var(--radius-full);
- background: var(--surface-2);
- border: 1px solid var(--border);
- color: var(--text-2);
- transition: all var(--dur-fast) var(--ease);
-}
-.tier-card.is-selected.strawberry .tier-select-pill.on {
- background: var(--strawberry); color: var(--on-strawberry); border-color: var(--strawberry);
-}
-.tier-card.is-selected.bondi .tier-select-pill.on {
- background: var(--bondi); color: var(--on-bondi); border-color: var(--bondi);
-}
-.tier-card.is-selected.lime .tier-select-pill.on {
- background: var(--lime); color: var(--on-lime); border-color: var(--lime);
-}
-
-/* API integration: alert, chips, checkboxes, spinner */
-.onb-alert {
- border-radius: var(--radius-md);
- padding: 12px 14px;
- margin-bottom: 18px;
- font-size: 13px;
-}
-.onb-alert.err {
- background: var(--strawberry-soft);
- border: 1px solid rgba(244,102,136,0.40);
- color: var(--text);
-}
-.onb-alert.warn {
- background: var(--tangerine-soft);
- border: 1px solid rgba(255,149,64,0.40);
- color: var(--text);
-}
-.onb-alert-head {
- display: flex; align-items: center; gap: 10px;
- font-size: 13px;
-}
-.onb-alert-code {
- font-family: var(--font-mono);
- font-size: 10.5px;
- letter-spacing: 0.12em;
- text-transform: uppercase;
- padding: 3px 8px;
- border-radius: var(--radius-sm);
- background: rgba(0,0,0,0.30);
- color: var(--text-strong);
-}
-.onb-alert-detail {
- display: flex; flex-direction: column; gap: 8px;
- margin-top: 10px;
- padding-top: 10px;
- border-top: 1px solid rgba(255,255,255,0.10);
-}
-.onb-alert-label {
- display: inline-block;
- font-family: var(--font-mono);
- font-size: 9.5px;
- letter-spacing: 0.14em;
- color: var(--muted);
- margin-right: 8px;
- text-transform: uppercase;
-}
-.onb-chip {
- display: inline-block;
- font-family: var(--font-mono);
- font-size: 10.5px;
- padding: 2px 7px;
- border-radius: var(--radius-sm);
- margin: 2px 4px 2px 0;
- letter-spacing: 0.06em;
-}
-.onb-chip.miss { background: var(--strawberry-soft); color: var(--strawberry); border: 1px solid rgba(244,102,136,0.30); }
-.onb-chip.ok { background: var(--lime-soft); color: var(--lime); border: 1px solid rgba(181,220,85,0.30); }
-
-.onb-checkboxes { display: flex; gap: 8px; flex-wrap: wrap; }
-.onb-chip-btn {
- font-family: var(--font-mono);
- font-size: 11px;
- letter-spacing: 0.06em;
- padding: 7px 12px;
- border-radius: var(--radius-full);
- background: var(--surface);
- border: 1px solid var(--border);
- color: var(--text-2);
- cursor: pointer;
- transition: all var(--dur-fast) var(--ease);
-}
-.onb-chip-btn:hover { color: var(--text); border-color: var(--border-strong); }
-.onb-chip-btn.on {
- background: var(--bondi-soft);
- border-color: rgba(43,202,232,0.40);
- color: var(--bondi);
-}
-.onb-chip-btn[disabled] { opacity: 0.5; cursor: not-allowed; }
-
-.onb-spinner {
- display: inline-block;
- width: 12px; height: 12px;
- border: 2px solid rgba(255,255,255,0.30);
- border-top-color: currentColor;
- border-radius: 50%;
- margin-right: 6px;
- vertical-align: -1px;
- animation: onb-spin 600ms linear infinite;
-}
-@keyframes onb-spin { to { transform: rotate(360deg); } }
-
-.onb-toast-sub code {
- font-family: var(--font-mono);
- font-size: 10px;
- background: rgba(255,255,255,0.06);
- padding: 1px 5px;
- border-radius: 4px;
- color: var(--text);
-}
-
-/* DATA CLASSES field shouldn't stretch input style */
-.onb-field .onb-checkboxes { padding-top: 4px; }
-
-/* Bottom action bar */
-.onb-action-bar {
- display: flex; justify-content: space-between; align-items: center;
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius-lg);
- padding: 14px 18px;
-}
-.onb-action-summary { display: flex; flex-direction: column; gap: 2px; }
-.onb-action-label {
- font-family: var(--font-mono);
- font-size: 9.5px;
- letter-spacing: 0.14em;
- color: var(--muted);
-}
-.onb-action-value {
- font-family: var(--font-display);
- font-size: 16px;
- font-weight: 500;
- color: var(--text-strong);
- letter-spacing: -0.01em;
-}
-.onb-action-dot { color: var(--muted); font-weight: 400; }
-.onb-action-unit {
- font-family: var(--font-mono);
- font-size: 11px;
- color: var(--muted);
- letter-spacing: 0.05em;
-}
-.onb-action-buttons { display: flex; gap: 10px; }
-.onb-btn {
- padding: 9px 18px;
- border-radius: var(--radius-md);
- font-family: var(--font-text);
- font-size: 13px;
- font-weight: 500;
- cursor: pointer;
- transition: all var(--dur-fast) var(--ease);
-}
-.onb-btn.ghost {
- background: transparent;
- border: 1px solid var(--border);
- color: var(--text-2);
-}
-.onb-btn.ghost:hover { color: var(--text); border-color: var(--border-strong); background: var(--surface-2); }
-.onb-btn.primary {
- background: linear-gradient(135deg, var(--aqua), var(--bondi));
- border: 1px solid rgba(91,160,240,0.45);
- color: var(--bg);
- font-weight: 600;
- box-shadow: 0 0 18px rgba(91,160,240,0.25);
-}
-.onb-btn.primary:hover {
- box-shadow: 0 0 28px rgba(91,160,240,0.45);
- transform: translateY(-1px);
-}
-
-/* Success toast (shown after onboarding, dismissed automatically) */
-.onb-toast {
- position: absolute;
- right: 24px; bottom: 24px;
- z-index: 60;
- display: flex; align-items: center; gap: 12px;
- background: var(--surface);
- border: 1px solid var(--lime);
- border-radius: var(--radius-md);
- padding: 12px 16px 12px 12px;
- box-shadow: 0 12px 36px rgba(0,0,0,0.40), 0 0 24px var(--lime-glow);
- animation: onb-toast-in 280ms var(--ease-spring);
- max-width: 360px;
-}
-.onb-toast-icon {
- flex-shrink: 0;
- width: 28px; height: 28px;
- border-radius: 50%;
- background: var(--lime);
- color: var(--on-lime);
- display: flex; align-items: center; justify-content: center;
- font-family: var(--font-mono);
- font-weight: 700;
-}
-.onb-toast-title { font-size: 13px; color: var(--text); }
-.onb-toast-sla { color: var(--lime); font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.1em; }
-.onb-toast-sub { font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); margin-top: 2px; letter-spacing: 0.06em; }
-@keyframes onb-toast-in {
- from { opacity: 0; transform: translateY(8px); }
- to { opacity: 1; transform: translateY(0); }
-}
-
diff --git a/apps/web/public/app/brand.jsx b/apps/web/public/app/brand.jsx
deleted file mode 100644
index c8deafa..0000000
--- a/apps/web/public/app/brand.jsx
+++ /dev/null
@@ -1,30 +0,0 @@
-// brand.jsx — the Unsyphn wordmark
-
-function UnsyphnMark({ size = 18 }) {
- return (
-

- );
-}
-
-function UnsyphnDot({ size = 16, animate = true }) {
- return (
-
- );
-}
-
-Object.assign(window, { UnsyphnMark, UnsyphnDot });
diff --git a/apps/web/public/app/browser-window.jsx b/apps/web/public/app/browser-window.jsx
deleted file mode 100644
index b90e273..0000000
--- a/apps/web/public/app/browser-window.jsx
+++ /dev/null
@@ -1,114 +0,0 @@
-
-// Chrome.jsx — Simplified Chrome browser window (dark theme, macOS)
-// No dependencies, no image assets. All inline styles + inline SVG.
-
-const CHROME_C = {
- barBg: '#202124',
- tabBg: '#35363a',
- text: '#e8eaed',
- dim: '#9aa0a6',
- urlBg: '#282a2d',
-};
-
-function ChromeTrafficLights() {
- return (
-
- );
-}
-
-// Single tab (active has curved scoops)
-function ChromeTab({ title = 'New Tab', active = false }) {
- const curve = (flip) => (
-
- );
- return (
-
- {active && curve(false)}
- {active && curve(true)}
-
-
{title}
-
- );
-}
-
-function ChromeTabBar({ tabs = [{ title: 'New Tab' }], activeIndex = 0 }) {
- return (
-
-
-
- {tabs.map((t, i) => )}
-
-
- );
-}
-
-function ChromeToolbar({ url = 'example.com' }) {
- const iconDot = (
-
- );
- return (
-
- {iconDot}
- {/* url bar */}
-
- {iconDot}
-
- );
-}
-
-function ChromeWindow({
- tabs = [{ title: 'New Tab' }], activeIndex = 0, url = 'example.com',
- width = 900, height = 600, children,
-}) {
- return (
-
- );
-}
-
-Object.assign(window, {
- ChromeWindow, ChromeTabBar, ChromeToolbar, ChromeTab, ChromeTrafficLights,
-});
diff --git a/apps/web/public/app/escalate.jsx b/apps/web/public/app/escalate.jsx
deleted file mode 100644
index 0d974a4..0000000
--- a/apps/web/public/app/escalate.jsx
+++ /dev/null
@@ -1,96 +0,0 @@
-// escalate.jsx — Escalate-to-Legal modal
-
-function EscalateModal({ state, dispatch }) {
- const [msg, setMsg] = React.useState(
- "Notion ToS change: PII retention 90→30d violates DPA §3.1. Pricing +18% with renewal in 42 days. Negotiate both: retention back to 60d (industry standard), price locked at prior $10/seat."
- );
- const [deadline, setDeadline] = React.useState("2026-05-29");
-
- return (
-
{ if (e.target === e.currentTarget) dispatch({ type: "close-escalate" }); }}>
-
-
-
-
⚠ P1 · ESCALATE
-
Route ChangeReport RL·4839 to Legal.
-
-
-
-
-
-
-
-
Recipients2 · BOTH NOTIFIED
-
-
-
PN
-
-
Priya Natarajan
-
LEGAL COUNSEL
-
-
-
-
MC
-
-
Marcus Chen
-
PROCUREMENT
-
-
-
-
-
-
-
SeverityLOCKED · POLICY-DRIVEN
-
-
-
-
-
-
-
Citations to attach✓ 4 GROUNDED
-
-
✓notion.so/terms#4.2 · §4.2 Data Retention8b2e…f94a
-
✓notion.so/pricing · §7.1 Plus Pland4a1…2c7b
-
✓internal · DPA §3.1 (Acme ⟷ Notion)f9c3…81de
-
✓policy · severity-rules.yaml · v4a3f9…d21c
-
-
-
-
-
-
-
Deadline
-
setDeadline(e.target.value)} />
-
-
-
-
-
-
-
- All actions logged · 4 citations · agent-redline-v1.4
-
-
-
-
-
-
-
-
- );
-}
-
-Object.assign(window, { EscalateModal });
diff --git a/apps/web/public/app/index.html b/apps/web/public/app/index.html
deleted file mode 100644
index ce2eefe..0000000
--- a/apps/web/public/app/index.html
+++ /dev/null
@@ -1,69 +0,0 @@
-
-
-
-
-
-
-
Unsyphn — Working Demo (Notion flow)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/web/public/app/live.js b/apps/web/public/app/live.js
deleted file mode 100644
index da82b3e..0000000
--- a/apps/web/public/app/live.js
+++ /dev/null
@@ -1,187 +0,0 @@
-// Sidecar live layer for the static demo app.
-// Loaded AFTER the React/Babel pipeline finishes hydrating. Polls for the
-// FleetStats cards (rendered by shared.jsx), fetches the Unsyphn dashboard
-// summary, and patches the matching stat-val numbers in place — without
-// touching any JSX file.
-//
-// Strict contract:
-// * No edits to any .jsx file.
-// * Silent no-op if API unreachable or shape mismatched.
-// * One window event emitted: `redline:live-summary` carrying the parsed body.
-// * One window event emitted on failure: `redline:live-summary-error`.
-//
-// API base + bearer are read from
tags so the same script works in
-// dev (localhost:8787) and on Vercel (proxied /v1).
-
-(function () {
- "use strict";
-
- function metaValue(name, fallback) {
- var el = document.querySelector('meta[name="' + name + '"]');
- return el && el.content ? el.content : fallback;
- }
-
- var API_BASE = metaValue("redline-api-base", "");
- var BEARER = metaValue("redline-bearer", "demo_token_acme_corp_2026");
-
- // In dev, Vite serves the static demo on a different port than the API.
- // Detect this: if no meta base is set and we're on a non-API port, point
- // directly at the known API port.
- if (!API_BASE && typeof window !== "undefined") {
- var h = window.location.hostname;
- var p = window.location.port;
- // Port 4004 = vite dev server; API lives on 3005
- if (p === "4004" || p === "5173" || p === "3000") {
- API_BASE = window.location.protocol + "//" + h + ":3005";
- }
- }
-
- var baseUrl = (API_BASE || (typeof window !== "undefined" && window.location.origin) || "").replace(/\/+$/, "");
- var SUMMARY_URL = baseUrl + "/v1/dashboard/summary";
- var STREAM_URL = baseUrl + "/v1/stream?token=" + encodeURIComponent(BEARER);
-
- // Format helpers — keep numbers terse so they fit the existing tile size.
- function compactUsd(value) {
- if (value == null || isNaN(value)) return null;
- if (value >= 1_000_000) return "$" + (value / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
- if (value >= 1_000) return "$" + Math.round(value / 1_000) + "k";
- return "$" + Math.round(value);
- }
-
- function findStatByLabel(label) {
- var nodes = document.querySelectorAll(".fleet .stat");
- for (var i = 0; i < nodes.length; i++) {
- var lbl = nodes[i].querySelector(".stat-label");
- if (lbl && lbl.textContent.trim().toLowerCase() === label.toLowerCase()) {
- return nodes[i];
- }
- }
- return null;
- }
-
- function setVal(card, value) {
- if (!card || value == null) return false;
- var v = card.querySelector(".stat-val");
- if (!v) return false;
- v.textContent = String(value);
- card.setAttribute("data-live", "1");
- return true;
- }
-
- function setSub(card, value) {
- if (!card || !value) return;
- var s = card.querySelector(".stat-sub");
- if (s) s.textContent = value;
- }
-
- function applySummary(summary) {
- if (!summary || typeof summary !== "object") return 0;
- var patched = 0;
-
- var vendors = findStatByLabel("Vendors");
- if (vendors && setVal(vendors, summary.vendorCount)) {
- var rr = compactUsd(summary.annualRunRateUsd);
- if (rr) setSub(vendors, "live · " + rr + " run-rate");
- patched += 1;
- }
-
- var openChanges = findStatByLabel("P1 · critical");
- if (openChanges && setVal(openChanges, summary.openChangeCount)) {
- patched += 1;
- }
-
- return patched;
- }
-
- function waitForFleet(maxMs, cb) {
- var deadline = Date.now() + maxMs;
- function tick() {
- var nodes = document.querySelectorAll(".fleet .stat");
- if (nodes.length > 0) return cb(true);
- if (Date.now() > deadline) return cb(false);
- setTimeout(tick, 100);
- }
- tick();
- }
-
- function fetchSummary() {
- return fetch(SUMMARY_URL, {
- headers: {
- Authorization: "Bearer " + BEARER,
- Accept: "application/json",
- },
- mode: "cors",
- credentials: "omit",
- }).then(function (res) {
- if (!res.ok) throw new Error("HTTP " + res.status);
- return res.json();
- });
- }
-
- function dispatchEvent(name, detail) {
- try {
- window.dispatchEvent(new CustomEvent(name, { detail: detail }));
- } catch (_) {
- // CustomEvent unsupported — silent.
- }
- }
-
- function subscribeToStream() {
- if (typeof EventSource === "undefined") return;
- var es = new EventSource(STREAM_URL);
-
- // On any named or unnamed message, re-fetch summary to keep stats fresh.
- var REFRESH_EVENTS = ["scheduler.tick", "run.stage", "run.completed", "org.entitlements.changed"];
- REFRESH_EVENTS.forEach(function (evtType) {
- es.addEventListener(evtType, function (e) {
- var payload = null;
- try { payload = JSON.parse(e.data); } catch (_) {}
- dispatchEvent("redline:stream-event", { type: evtType, payload: payload });
- fetchSummary().then(function (summary) {
- waitForFleet(1000, function (mounted) {
- if (mounted) applySummary(summary);
- });
- }).catch(function () {});
- });
- });
-
- es.onerror = function () {
- // Reconnect is handled automatically by EventSource; no action needed.
- };
- }
-
- function start() {
- waitForFleet(5000, function (mounted) {
- if (!mounted) {
- dispatchEvent("redline:live-summary-error", { reason: "fleet-not-mounted" });
- return;
- }
- fetchSummary()
- .then(function (summary) {
- var patched = applySummary(summary);
- dispatchEvent("redline:live-summary", { summary: summary, patched: patched });
- subscribeToStream();
- })
- .catch(function (err) {
- dispatchEvent("redline:live-summary-error", { reason: String(err && err.message ? err.message : err) });
- subscribeToStream();
- });
- });
- }
-
- // Export for tests
- if (typeof module !== "undefined" && module.exports) {
- module.exports = { applySummary: applySummary, compactUsd: compactUsd, findStatByLabel: findStatByLabel };
- }
- if (typeof window !== "undefined") {
- window.__redlineLive = { applySummary: applySummary, compactUsd: compactUsd, findStatByLabel: findStatByLabel };
- }
-
- if (typeof document !== "undefined") {
- if (document.readyState === "loading") {
- document.addEventListener("DOMContentLoaded", start, { once: true });
- } else {
- start();
- }
- }
-})();
diff --git a/apps/web/public/app/routing.jsx b/apps/web/public/app/routing.jsx
deleted file mode 100644
index 2d4ab86..0000000
--- a/apps/web/public/app/routing.jsx
+++ /dev/null
@@ -1,40 +0,0 @@
-// routing.jsx — full-screen routing transition (streams log, auto-advances to evidence).
-// Pulls the log + title from VENDOR_DATA[activeVendor]; falls back to Notion's defaults.
-
-function RoutingTransition({ dispatch, vendorKey }) {
- const DATA = window.VENDOR_DATA || {};
- const vendor = DATA[vendorKey || "notion"] || DATA.notion || {};
- const log = vendor.routingLog || [];
- const title = vendor.routingTitle || <>Routing in progress…>;
-
- const [shown, setShown] = React.useState(0);
-
- React.useEffect(() => {
- if (shown >= log.length) {
- const t = setTimeout(() => dispatch({ type: "finish-routing" }), 1100);
- return () => clearTimeout(t);
- }
- const t = setTimeout(() => setShown((s) => s + 1), 220);
- return () => clearTimeout(t);
- }, [shown, dispatch, log.length]);
-
- const visible = log.slice(0, shown);
-
- return (
-
-
-
{title}
-
- {visible.map((l, i) => (
-
- {l.ts}
- {l.lvl.toUpperCase()}
- {l.msg}
-
- ))}
-
-
- );
-}
-
-Object.assign(window, { RoutingTransition });
diff --git a/apps/web/public/app/screen-change.jsx b/apps/web/public/app/screen-change.jsx
deleted file mode 100644
index ebc35e5..0000000
--- a/apps/web/public/app/screen-change.jsx
+++ /dev/null
@@ -1,358 +0,0 @@
-// screen-change.jsx — ChangeReport detail screen.
-// Reads window.VENDOR_DATA[state.activeVendor]. Notion is the interactive
-// hero flow (P1 → ROUTED via state.notion); other vendors are pre-baked
-// in one of: open (P1/P2 unrouted), acknowledged (P1 already routed),
-// healthy (no recent diff).
-
-function ScreenChange({ state, dispatch }) {
- const DATA = window.VENDOR_DATA || {};
- const vendor = DATA[state.activeVendor];
-
- if (!vendor) {
- return (
-
-
-
- );
- }
-
- const isNotion = vendor.key === "notion";
- const mode = isNotion
- ? (state.notion === "ROUTED" ? "routed" : "open")
- : (vendor.mode || "open");
- const isRouted = mode === "routed";
- const isAck = mode === "acknowledged";
- const isHealthy = mode === "healthy";
- const isWitnessed = isRouted || isAck;
-
- const cr = vendor.cr || {};
-
- const sevBadgeCls = isRouted ? "routed" : isAck ? "ack" : isHealthy ? "healthy" : vendor.sev;
- const sevBadgeText = isRouted ? "✓ ROUTED · WITNESSED"
- : isAck ? "✓ ACKNOWLEDGED · WITNESSED"
- : isHealthy ? "✓ HEALTHY"
- : `⚠ ${vendor.sevLabel} · ${vendor.sevLabel === "P1" ? "CRITICAL" : vendor.sevLabel === "P2" ? "REVIEW" : "NOTE"}`;
-
- const ownerName = vendor.owner.name;
- const crumbCurrent = cr.bundleId ? `CR · ${cr.bundleId}` : "CR · scan";
-
- return (
-
-
-
- {/* Crumbs */}
-
-
- dispatch({ type: "goto", screen: "portfolio" })}>Portfolio
- ›
- {vendor.name}
- ›
- {crumbCurrent}
-
-
-
-
-
-
- {/* Vendor strip */}
-
-
{vendor.letter}
-
-
{vendor.name}
-
- {vendor.category}
- TIER {vendor.tier}
- OWNER · {ownerName}
- ANNUAL · {vendor.annual}
- {vendor.seats > 0 && SEATS · {vendor.seats}}
-
-
-
-
{vendor.renewsInDays}d
-
Until renewal
-
-
-
-
-
- {/* Report card */}
-
-
-
{sevBadgeText}
- {(cr.categories || []).map((c) => (
-
{c}
- ))}
-
-
- {isRouted && cr.titleRouted ? cr.titleRouted : cr.titleOpen}
-
-
- {isRouted ? (
- <>ROUTED · {cr.routedAt} · {cr.routedWhen} · BY · {cr.agent} · GROUNDED · ✓ {cr.citationCount} citations · BUNDLE · {cr.bundleId}>
- ) : isAck ? (
- <>ACKNOWLEDGED · {cr.acknowledgedAt} · {cr.acknowledgedWhen} · BY · {cr.agent} · GROUNDED · ✓ {cr.citationCount} citations · BUNDLE · {cr.bundleId}>
- ) : isHealthy ? (
- <>LAST SCAN · {cr.detectedAt} · {cr.detectedWhen} · BY · {cr.agent} · NO DIFF DETECTED>
- ) : (
- <>DETECTED · {cr.detectedAt} · {cr.detectedWhen} · BY · {cr.agent} · GROUNDED · ✓ {cr.citationCount} citations validated>
- )}
-
-
- {Array.isArray(cr.impacts) && cr.impacts.length > 0 && (
-
- {cr.impacts.map((imp, i) => (
-
-
{imp.lbl}
-
{imp.val}
-
- ))}
-
- )}
-
- {Array.isArray(cr.diffs) && cr.diffs.map((d, i) => (
-
-
{d.label}
-
-
-
- − BEFORE
- {d.before.when}
-
-
{d.before.text}
-
{d.before.source}
-
-
-
- + AFTER
- {d.after.when}
-
-
{d.after.text}
-
-
-
-
- ))}
-
- {isHealthy && Array.isArray(cr.lastScannedSurfaces) && (
-
-
Monitored surfaces · all stable
-
-
- ✓ STABLE
- LAST 14d
-
-
- {cr.lastScannedSurfaces.map((s, i) => (
-
- ✓
- {s.url}
- {s.when}
- {s.hash}
-
- ))}
-
-
-
- )}
-
-
-
- {isRouted && cr.recoRouted ? cr.recoRouted.head
- : isAck && cr.recoRouted ? cr.recoRouted.head
- : cr.recoOpen && cr.recoOpen.head}
-
-
- {isRouted && cr.recoRouted ? cr.recoRouted.text
- : isAck && cr.recoRouted ? cr.recoRouted.text
- : cr.recoOpen && cr.recoOpen.text}
-
-
-
-
- {/* Side panels */}
-
-
-
-
-
- {/* Sticky action bar */}
-
- {isRouted ? (
- <>
-
-
-
-
-
-
- ✓ ROUTED · WITNESSED · {state.routeTime || "14:59:18 EST"}
-
-
- >
- ) : isAck ? (
- <>
-
-
-
-
-
-
- ✓ ACKNOWLEDGED · {cr.acknowledgedAt}
-
-
- >
- ) : isHealthy ? (
- <>
-
-
-
- ✓ POSTURE STABLE · next scan in 6h
-
-
- >
- ) : isNotion ? (
- <>
-
-
-
-
-
-
-
-
-
- >
- ) : (
- <>
-
-
-
-
-
-
-
-
-
- >
- )}
-
-
- );
-}
-
-Object.assign(window, { ScreenChange });
diff --git a/apps/web/public/app/screen-evidence.jsx b/apps/web/public/app/screen-evidence.jsx
deleted file mode 100644
index 5febc08..0000000
--- a/apps/web/public/app/screen-evidence.jsx
+++ /dev/null
@@ -1,201 +0,0 @@
-// screen-evidence.jsx — Evidence bundle preview (the generated artifact).
-// Pulls bundle data from VENDOR_DATA[state.activeVendor].bundle.
-
-function ScreenEvidence({ state, dispatch }) {
- const DATA = window.VENDOR_DATA || {};
- const vendor = DATA[state.activeVendor];
-
- if (!vendor || !vendor.bundle) {
- return (
-
-
-
- );
- }
-
- const b = vendor.bundle;
- const crCrumb = vendor.cr.bundleId ? `${vendor.name} · ${vendor.cr.bundleId}` : vendor.name;
-
- return (
-
-
-
- {/* Crumbs */}
-
-
- dispatch({ type: "goto", screen: "portfolio" })}>Portfolio
- ›
- dispatch({ type: "goto", screen: "change" })}>{crCrumb}
- ›
- Evidence Bundle
-
-
-
-
-
-
- {/* Bundle header */}
-
-
{b.seal}
-
-
Evidence Bundle · {b.id}
-
- {b.eyebrow}
- SIGNED · {b.signedAt}
- WITNESSED · agent-redline-v1.4
- HASH · {b.hash}
- {(b.citations || []).length} CITATIONS · GROUNDED
-
-
-
✓ WITNESSED
-
-
-
-
- {/* Document */}
-
-
-
-
⌁ Evidence Bond · No. {b.id} ⌁
-
UNSYPHN
-
- {b.coverSubtitle}
-
-
- ISSUED · MAY 22 2026
- {vendor.sevLabel} · {vendor.sev === "p1" ? "CRITICAL" : vendor.sev === "p2" ? "REVIEW" : "NOTE"}
- BUNDLE · {b.id}
- WITNESS · agent-redline-v1.4
-
-
-
-
-
1Clause diffs
-
-
- {(b.miniDiffs || []).length === 1
- ? <>One clause on {vendor.name.toLowerCase()}'s monitored surfaces changed between snapshots:>
- : <>{(b.miniDiffs || []).length} clauses on {vendor.name.toLowerCase()}'s monitored surfaces changed between snapshots:>}
-
- {(b.miniDiffs || []).map((pair, i) => (
-
- {pair.map((mc, j) => (
-
- ))}
-
- ))}
-
-
-
-
-
-
-
3Routing trail
-
- {(b.trail || []).map((r, i) => (
-
- {r.ts}
- {r.msg}
- {r.status}
-
- ))}
-
-
-
-
-
4Citations · grounded
-
- {(b.citations || []).map((c, i) => (
-
- ✓
- {c.src}
- {c.hash}
-
- ))}
-
-
-
-
-
- — Witnessed by agent-redline-v1.4
- {b.signedAt} · grounded · routed · immutable
-
-
UNSYPHN
-
-
-
-
- {/* Side: TOC + actions */}
-
-
-
-
-
- {/* Action bar */}
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-Object.assign(window, { ScreenEvidence });
diff --git a/apps/web/public/app/screen-onboarding.jsx b/apps/web/public/app/screen-onboarding.jsx
deleted file mode 100644
index 3dcb4b4..0000000
--- a/apps/web/public/app/screen-onboarding.jsx
+++ /dev/null
@@ -1,351 +0,0 @@
-// screen-onboarding.jsx — Vendor onboarding tier selection (6H / 24H / 48H SLA)
-// Posts to POST /v1/vendors via the vite proxy. SLA tier → criticality tier
-// mapping is: 6H Express → 1 (critical), 24H Standard → 2 (material), 48H Basic → 3 (informational).
-
-// Bearer token sourced from the same meta tag live.js reads so it can be
-// rotated/overridden in one place (public/app/index.html line 7).
-function getBearerToken() {
- if (typeof document === "undefined") return "";
- const meta = document.querySelector('meta[name="redline-bearer"]');
- return (meta && meta.getAttribute("content")) || "";
-}
-const DEMO_BEARER_TOKEN = getBearerToken();
-
-const OWNERS = [
- { id: "usr_priya", name: "Priya Natarajan" },
- { id: "usr_marcus", name: "Marcus Chen" },
- { id: "usr_lin", name: "Lin Park" },
- { id: "usr_jordan", name: "Jordan Wells" },
- { id: "usr_ada", name: "Ada Owens" },
- { id: "usr_devon", name: "Devon Rao" },
-];
-
-const DATA_CLASSES = [
- { id: "pii", label: "PII" },
- { id: "phi", label: "PHI" },
- { id: "financial", label: "Financial" },
- { id: "content", label: "Content" },
-];
-
-const SLA_TO_CRITICALITY = { "6h": 1, "24h": 2, "48h": 3 };
-
-function ScreenOnboarding({ state, dispatch }) {
- const [selected, setSelected] = React.useState(state.onboardingTier || "24h");
- const [name, setName] = React.useState("");
- const [homepage, setHomepage] = React.useState("");
- const [ownerId, setOwnerId] = React.useState("usr_priya");
- const [dataClasses, setDC] = React.useState(["pii"]);
- const [submitting, setSubmit] = React.useState(false);
- const [apiError, setApiError] = React.useState(null);
- const [discovered, setDiscover] = React.useState(null);
-
- const tiers = [
- {
- id: "6h", role: "strawberry", sla: "6H", tagline: "Express",
- name: "6-Hour Express", price: "$999", unit: "/ vendor / month",
- desc: "For Tier-1, revenue-critical vendors. Sub-six-hour detection on every ToS, DPA, or sub-processor change.",
- features: [
- "6-hour SLA on change detection",
- "Hourly diff scans, 24 / 7 / 365",
- "Dedicated #redline-p1 escalation channel",
- "Auto-route to on-call within 90 seconds",
- "Compliance bundle on every change",
- "Quarterly auditor walkthrough",
- ],
- eta: "FIRST SCAN IN ~6 MIN",
- },
- {
- id: "24h", role: "bondi", sla: "24H", tagline: "Standard",
- name: "24-Hour Standard", price: "$299", unit: "/ vendor / month",
- desc: "The right default for most vendors. Daily monitoring with automated routing to owners.",
- features: [
- "24-hour SLA on change detection",
- "Daily automated diff scans",
- "Slack + email routing to owner",
- "Quarterly evidence bundles",
- "Standard 9-5 support",
- "Vanta / Drata push every 90d",
- ],
- eta: "FIRST SCAN IN ~30 MIN",
- recommended: true,
- },
- {
- id: "48h", role: "lime", sla: "48H", tagline: "Basic",
- name: "48-Hour Basic", price: "$99", unit: "/ vendor / month",
- desc: "Lightweight coverage for low-priority and long-tail vendors. Routine checks, weekly digests.",
- features: [
- "48-hour SLA on change detection",
- "Bi-daily routine scans",
- "Weekly digest email to owner",
- "Self-serve evidence export",
- "Community support",
- "Manual escalation only",
- ],
- eta: "FIRST SCAN IN ~2 HRS",
- },
- ];
-
- const goBack = () => dispatch({ type: "goto", screen: "portfolio" });
-
- const currentTier = tiers.find((t) => t.id === selected) || tiers[1];
-
- function toggleDataClass(id) {
- setDC((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]);
- }
-
- async function submit() {
- setApiError(null);
- setDiscover(null);
-
- const trimmedName = name.trim();
- const trimmedHomepage = homepage.trim();
- if (trimmedName.length < 2) {
- setApiError({ code: "validation-failed", message: "Vendor name must be at least 2 characters." });
- return;
- }
- if (!/^https?:\/\//i.test(trimmedHomepage)) {
- setApiError({ code: "validation-failed", message: "Homepage must start with http:// or https://" });
- return;
- }
-
- setSubmit(true);
- try {
- const resp = await fetch("/v1/vendors", {
- method: "POST",
- headers: {
- "Authorization": "Bearer " + DEMO_BEARER_TOKEN,
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- name: trimmedName,
- homepageUrl: trimmedHomepage,
- ownerId,
- tier: SLA_TO_CRITICALITY[selected],
- dataClasses,
- }),
- });
-
- const text = await resp.text();
- const json = text ? JSON.parse(text) : null;
-
- if (!resp.ok) {
- const err = (json && json.error) || { code: "unknown", message: "Request failed (HTTP " + resp.status + ")" };
- setApiError(err);
- if (err.code === "discovery-incomplete" && err.details) {
- setDiscover({ found: err.details.found || {}, missing: err.details.missing || [] });
- }
- setSubmit(false);
- return;
- }
-
- dispatch({
- type: "vendor-onboarded",
- tier: selected,
- tierLabel: currentTier.sla,
- name: json.name,
- vendorId: json.id,
- firstScanRunId: json.firstScanRunId,
- discoveredUrls: json.discoveredUrls,
- });
- } catch (e) {
- setApiError({ code: "network", message: e && e.message ? e.message : "Network error" });
- setSubmit(false);
- }
- }
-
- return (
-
-
-
-
-
- Portfolio
- ›
- Onboard vendor
-
-
-
-
-
-
-
-
-
-
STEP 1 OF 1 · CHOOSE SLA
-
- Onboard a new vendor.
-
-
- Pick the detection SLA that matches the vendor's risk profile. You can re-tier any vendor later from the portfolio — this only changes scan cadence and routing.
-
-
-
-
-
- {apiError && (
-
-
- {apiError.code}
- {apiError.message}
-
- {discovered && (
-
- {discovered.missing.length > 0 && (
-
- MISSING
- {discovered.missing.map((m) => (
- {m}
- ))}
-
- )}
- {Object.keys(discovered.found).length > 0 && (
-
- FOUND
- {Object.keys(discovered.found).map((k) => (
- {k}
- ))}
-
- )}
-
- )}
-
- )}
-
-
-
-
-
-
-
DATA CLASSES
-
- {DATA_CLASSES.map((dc) => {
- const on = dataClasses.includes(dc.id);
- return (
-
- );
- })}
-
-
-
-
-
- {tiers.map((t) => {
- const isSel = selected === t.id;
- return (
-
!submitting && setSelected(t.id)}
- >
- {t.recommended &&
RECOMMENDED
}
-
-
-
{t.sla}
-
{t.tagline}
-
-
-
{t.name}
-
{t.desc}
-
-
- {t.price}
- {t.unit}
-
-
-
- {t.features.map((f, i) => (
- - ✓{f}
- ))}
-
-
-
-
{t.eta}
-
- {isSel ? "✓ SELECTED" : "SELECT"}
-
-
-
- );
- })}
-
-
-
-
- SELECTED TIER
-
- {currentTier.sla}
- ·
- {currentTier.price}
- {currentTier.unit}
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-Object.assign(window, { ScreenOnboarding });
diff --git a/apps/web/public/app/screen-portfolio.jsx b/apps/web/public/app/screen-portfolio.jsx
deleted file mode 100644
index 7333d5a..0000000
--- a/apps/web/public/app/screen-portfolio.jsx
+++ /dev/null
@@ -1,163 +0,0 @@
-// screen-portfolio.jsx — Portfolio screen
-// Reads every vendor from window.VENDOR_DATA. Every card is clickable —
-// clicking jumps the change screen to that vendor's CR. Notion stays the
-// hero flow (P1 → ROUTED via state.notion); other vendors carry their
-// own pre-baked state.
-
-function ScreenPortfolio({ state, dispatch }) {
- const notionState = state.notion; // "P1" | "ROUTED"
- const DATA = window.VENDOR_DATA || {};
- const ORDER = window.VENDOR_ORDER || Object.keys(DATA);
-
- // Build portfolio cards from VENDOR_DATA. Notion's display swaps on routed.
- const vendors = ORDER.map((k) => {
- const v = DATA[k];
- if (!v) return null;
- const isNotion = k === "notion";
- const routed = isNotion && notionState === "ROUTED";
- return {
- key: k,
- letter: v.letter,
- name: v.name,
- meta: v.meta,
- sev: routed ? "routed" : v.sev,
- sevLabel: routed ? "ROUTED" : v.sevLabel,
- lastChange: routed
- ? { label: "now · ROUTED TO LEGAL", cls: "live" }
- : v.lastChange,
- renews: { label: v.renewsLabel, cls: v.renewsCls },
- annual: v.annual,
- chips: v.dataClasses,
- owner: v.owner,
- };
- }).filter(Boolean);
-
- // Activity feed — pull from every vendor that has an `activity` entry.
- // The routed "just now" row sits on top of Notion's normal row when state.notion === ROUTED.
- const baseActivity = ORDER
- .map((k) => DATA[k])
- .filter((v) => v && v.activity)
- .map((v) => ({
- ...v.activity,
- v: v.name,
- key: v.key,
- clickable: true,
- }));
-
- // Slack as a P3 footer row — passive entry only (no full CR view).
- baseActivity.push({
- sev: "p3", v: "Slack", w: "1d",
- title: "SOC2 Type II refreshed · 2026 report",
- meta: "SECURITY", impact: "CLEARED", impactCls: "out",
- clickable: false,
- });
-
- const activity = notionState === "ROUTED"
- ? [
- { sev: "routed", v: "Notion", w: "just now",
- title: "ROUTED to Priya N. · Bundle RL·4839 generated",
- meta: "ESCALATED · LEGAL", impact: "WITNESSED", impactCls: "out",
- key: "notion", clickable: true, gotoBundle: true },
- ...baseActivity,
- ]
- : baseActivity;
-
- const openVendor = (key) => dispatch({ type: "open-vendor", vendor: key });
- const openBundle = (key) => dispatch({ type: "open-vendor-bundle", vendor: key });
-
- return (
-
-
- 8 SCANNING NOW
-
- {notionState === "P1" ? "3 P1 ACTIVE" : "2 P1 ACTIVE · 1 ROUTED"}
-
- >
- }
- />
-
-
-
-
-
-
-
-
The fleet
- VIEW ALL 27 →
-
-
- {vendors.map((v) => (
-
openVendor(v.key)}
- style={{ cursor: "pointer" }}
- >
-
-
- Last change
- {v.lastChange.label}
-
-
- Renews
- {v.renews.label}
-
-
- Annual
- {v.annual}
-
-
- {v.chips.map((c) =>
{c})}
-
{v.owner.initials}
-
-
- ))}
-
-
-
-
-
-
- );
-}
-
-Object.assign(window, { ScreenPortfolio });
diff --git a/apps/web/public/app/shared.jsx b/apps/web/public/app/shared.jsx
deleted file mode 100644
index 76ac4d5..0000000
--- a/apps/web/public/app/shared.jsx
+++ /dev/null
@@ -1,142 +0,0 @@
-// shared.jsx — sidebar, topbar, scan strip, fleet stats, activity feed
-// All consume `state` (read-only) and `dispatch` (navigation/mutations) from app.jsx.
-
-function Sidebar({ active, dispatch, state }) {
- const screenMap = { portfolio: "portfolio", changes: "change", evidence: "evidence" };
- const [flashKey, setFlashKey] = React.useState(null);
-
- React.useEffect(() => {
- if (!document.getElementById("unsyphn-nav-flash-style")) {
- const s = document.createElement("style");
- s.id = "unsyphn-nav-flash-style";
- s.textContent = ".nav-item-flash { background: var(--surface-2, rgba(255,255,255,0.08)) !important; opacity: 0.6; transition: opacity 0.15s; }";
- document.head.appendChild(s);
- }
- }, []);
-
- const items = [
- { key: "portfolio", label: "Portfolio", count: "27", section: "Workspace" },
- { key: "changes", label: "Changes", count: state.notion === "P1" ? "3 P1" : "2 P1", alert: state.notion === "P1", section: "Workspace" },
- { key: "renewals", label: "Renewals", count: "8", section: "Workspace" },
- { key: "policies", label: "Policies", count: "14", section: "Workspace" },
- { key: "evidence", label: "Evidence", count: state.evidenceCount || "432", section: "Workspace" },
- { key: "procurement", label: "Procurement", section: "Views" },
- { key: "legal", label: "Legal Ops", section: "Views" },
- { key: "grc", label: "Security · GRC", section: "Views" },
- { key: "finance", label: "Finance", section: "Views" },
- ];
- let lastSection = null;
- return (
-
- );
-}
-
-function TopBar({ title, when, right }) {
- return (
-
- );
-}
-
-function ScanStrip() {
- // Two copies for seamless tick loop
- const items = [
- { v: "Notion", t: "terms.notion.so", w: "scanning · 14:42:18", live: true },
- { v: "Stripe", t: "stripe.com/dpa", w: "scanning · 14:42:14", live: true },
- { v: "Datadog", t: "subprocessors.datadog", w: "scanning · 14:42:09", live: true },
- { v: "Linear", t: "linear.app/security", w: "queued · next 30s" },
- { v: "AWS", t: "aws.amazon.com/pricing", w: "queued · next 60s" },
- { v: "Figma", t: "figma.com/legal/sla", w: "14:41:55 · no diff" },
- { v: "Vercel", t: "vercel.com/legal/terms", w: "14:41:42 · no diff" },
- { v: "Slack", t: "slack.com/trust/security", w: "14:41:30 · diff" },
- ];
- const all = [...items, ...items];
- return (
-
-
LIVE
-
- {all.map((it, i) => (
-
- {it.v}
- {it.t}
- {it.w}
-
- ))}
-
-
- );
-}
-
-function FleetStats({ state }) {
- const p1Count = state.notion === "P1" ? 3 : 2;
- const cards = [
- { role: "aqua", lbl: "Vendors", val: "27", sub: "↑ 2 added this Q" },
- { role: "bondi", lbl: "Scanning", val: "8", sub: "↑ live polling now" },
- { role: "tangerine", lbl: "P2 · pending", val: "5", sub: "2 owners untouched 48h" },
- { role: "grape", lbl: "Sub-proc Δ", val: "11", sub: "1 in non-adequate jurisd." },
- { role: "strawberry", lbl: "P1 · critical", val: String(p1Count), sub: state.notion === "P1" ? "↑ $42k at risk" : "↓ $28k routed" },
- { role: "lime", lbl: "Healthy", val: "19", sub: "no action needed" },
- ];
- return (
-
- {cards.map((c, i) => (
-
-
- {c.lbl}
-
-
-
{c.val}
-
{c.sub}
-
- ))}
-
- );
-}
-
-Object.assign(window, { Sidebar, TopBar, ScanStrip, FleetStats });
diff --git a/apps/web/public/app/tokens.css b/apps/web/public/app/tokens.css
deleted file mode 100644
index 615c884..0000000
--- a/apps/web/public/app/tokens.css
+++ /dev/null
@@ -1,215 +0,0 @@
-/* =============================================================
- REDLINE · colors_and_type.css
- The full token system — colors, type, spacing, radii, motion.
- Drop this into any HTML file and use the vars below.
- ============================================================= */
-
-/* Font imports — Inter is shipped locally (variable, opsz + wght);
- Bricolage Grotesque (display) and JetBrains Mono + Instrument Serif
- are still pulled from Google Fonts until the team uploads replacements. */
-@import url("https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,500;12..96,600;12..96,700&family=JetBrains+Mono:wght@400;500;600&family=Instrument+Serif:ital@0;1&display=swap");
-
-@font-face {
- font-family: "Inter";
- src: url("fonts/Inter-VariableFont_opsz_wght.ttf") format("truetype-variations");
- font-weight: 100 900;
- font-style: normal;
- font-display: swap;
-}
-@font-face {
- font-family: "Inter";
- src: url("fonts/Inter-Italic-VariableFont_opsz_wght.ttf") format("truetype-variations");
- font-weight: 100 900;
- font-style: italic;
- font-display: swap;
-}
-
-:root {
- /* ── ROLE COLORS · the spine ─────────────────────────────────
- The same six hues mean the same six things across the system.
- ──────────────────────────────────────────────────────────── */
- --aqua: #5BA0F0; /* Navigation, vendor base, neutral data */
- --bondi: #2BCAE8; /* Live monitoring — scanning, polling */
- --tangerine: #FF9540; /* P2 severity, pending owner action */
- --grape: #A097D8; /* Sub-processor / data class (PII, PHI) */
- --strawberry: #F46688; /* P1 severity — critical, at-risk */
- --lime: #B5DC55; /* P3 / resolved / acknowledged / healthy */
-
- /* Soft variants — 10% alpha for tinted backgrounds */
- --aqua-soft: rgba(91, 160, 240, 0.10);
- --bondi-soft: rgba(43, 202, 232, 0.10);
- --tangerine-soft: rgba(255, 149, 64, 0.10);
- --grape-soft: rgba(160, 151, 216, 0.10);
- --strawberry-soft: rgba(244, 102, 136, 0.10);
- --lime-soft: rgba(181, 220, 85, 0.10);
-
- /* Glow variants — 30–32% alpha for box-shadow halos */
- --aqua-glow: rgba(91, 160, 240, 0.30);
- --bondi-glow: rgba(43, 202, 232, 0.32);
- --tangerine-glow: rgba(255, 149, 64, 0.30);
- --grape-glow: rgba(160, 151, 216, 0.30);
- --strawberry-glow: rgba(244, 102, 136, 0.30);
- --lime-glow: rgba(181, 220, 85, 0.32);
-
- /* ── AURORA SURFACES · solid, dense, working ─────────────── */
- --bg: #0A0A12; /* page background, under the sky wash */
- --surface: #14141E; /* primary card surface */
- --surface-2: #1E1E2A; /* nested surface, input fields */
- --surface-3: #28283A; /* hover / pressed state */
-
- --border: #2A2A3A;
- --border-strong: #3F3F50;
- --hairline: rgba(255, 255, 255, 0.06);
-
- /* ── HALO SURFACES · translucent, refractive, marketing ──── */
- --surface-tint: rgba(255, 255, 255, 0.04);
- --glass-border: rgba(255, 255, 255, 0.10);
- --glass-inset: rgba(255, 255, 255, 0.10); /* the inset top edge */
- --glass-specular: rgba(255, 255, 255, 0.08); /* the ::before strip */
-
- /* ── TEXT ─────────────────────────────────────────────── */
- --muted: #6E6E80;
- --text-2: #B0B0C0;
- --text: #ECECF5;
- --text-strong: #FFFFFF;
-
- /* Dark text-on-color (button labels on lime/strawberry) */
- --on-lime: #0F1A05;
- --on-strawberry: #2A0612;
- --on-tangerine: #2B1505;
- --on-bondi: #04212B;
-
- /* ── FONT FAMILIES · three jobs, never substitute ───────── */
- --font-display: "Bricolage Grotesque", -apple-system, BlinkMacSystemFont, sans-serif;
- --font-text: "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
- --font-mono: "JetBrains Mono", ui-monospace, monospace;
- --font-serif: "Instrument Serif", ui-serif, Georgia, serif;
-
- /* ── TYPE SCALE · vertical rhythm ───────────────────────── */
- --text-xs: 0.75rem; /* 12px — mono labels, eyebrows */
- --text-sm: 0.875rem; /* 14px — small UI, secondary text */
- --text-base: 0.9375rem; /* 15px — body */
- --text-lg: 1.0625rem; /* 17px — lead paragraphs */
- --text-xl: 1.375rem; /* 22px — h2 / card titles */
- --text-2xl: 2rem; /* 32px — h1 small */
- --text-3xl: 2.75rem; /* 44px — h1 hero */
- --text-4xl: 4rem; /* 64px — display */
- --text-5xl: 6.5rem; /* 104px — rainbow hero (Halo only) */
-
- /* ── SPACING · 4px base ─────────────────────────────────── */
- --space-1: 4px; --space-2: 8px; --space-3: 12px;
- --space-4: 16px; --space-5: 24px; --space-6: 36px;
- --space-7: 56px; --space-8: 80px;
-
- /* ── RADII ─────────────────────────────────────────────── */
- --radius-sm: 6px; /* inline chips, code */
- --radius-md: 10px; /* buttons, inputs, small cards */
- --radius-lg: 16px; /* primary cards, panels */
- --radius-xl: 24px; /* hero cards, the centerpiece */
- --radius-2xl: 32px; /* Halo hero only */
- --radius-full: 999px; /* pills, badges, the action bar */
-
- /* ── MOTION ─────────────────────────────────────────────── */
- --ease: cubic-bezier(0.22, 1, 0.36, 1);
- --ease-spring: cubic-bezier(0.34, 1.45, 0.64, 1);
- --dur-fast: 200ms;
- --dur-base: 400ms;
- --dur-slow: 700ms;
-
- /* ── ELEVATION · used sparingly ─────────────────────────── */
- --shadow-card: 0 8px 32px rgba(0, 0, 0, 0.20);
- --shadow-floating: 0 16px 48px rgba(0, 0, 0, 0.30);
- /* Halo cards also get inset 0 1px 0 rgba(255,255,255,0.10) */
-}
-
-/* =============================================================
- SEMANTIC TYPE STYLES
- ============================================================= */
-.h-hero,
-.h1-hero {
- font-family: var(--font-display);
- font-size: var(--text-3xl);
- font-weight: 500;
- letter-spacing: -0.04em;
- line-height: 1.05;
- color: var(--text-strong);
- font-variation-settings: "opsz" 96;
-}
-.h1 {
- font-family: var(--font-display);
- font-size: var(--text-2xl);
- font-weight: 500;
- letter-spacing: -0.025em;
- line-height: 1.1;
- color: var(--text-strong);
- font-variation-settings: "opsz" 48;
-}
-.h2 {
- font-family: var(--font-display);
- font-size: var(--text-xl);
- font-weight: 500;
- letter-spacing: -0.018em;
- line-height: 1.2;
- color: var(--text-strong);
- font-variation-settings: "opsz" 32;
-}
-.h3 {
- font-family: var(--font-display);
- font-size: 1.125rem; /* 18px */
- font-weight: 600;
- letter-spacing: -0.012em;
- color: var(--text-strong);
- font-variation-settings: "opsz" 24;
-}
-.lead {
- font-family: var(--font-text);
- font-size: var(--text-lg);
- font-weight: 400;
- line-height: 1.5;
- color: var(--text);
-}
-.p, p {
- font-family: var(--font-text);
- font-size: var(--text-base);
- font-weight: 400;
- line-height: 1.55;
- color: var(--text);
-}
-.small {
- font-family: var(--font-text);
- font-size: var(--text-sm);
- color: var(--text-2);
-}
-.eyebrow {
- font-family: var(--font-mono);
- font-size: var(--text-xs);
- font-weight: 500;
- letter-spacing: 0.12em;
- text-transform: uppercase;
- color: var(--muted);
-}
-.mono,
-.code,
-code {
- font-family: var(--font-mono);
- font-size: 0.8125rem; /* 13px */
- font-feature-settings: "tnum" 1, "zero" 1;
- color: var(--text);
-}
-.num,
-.numeric {
- font-feature-settings: "tnum" 1, "zero" 1;
-}
-.accent-italic {
- font-family: var(--font-serif);
- font-style: italic;
- font-weight: 400;
-}
-
-/* Universal: every number gets tabular */
-.tabular,
-[data-numeric],
-.stat-val,
-.impact-cell .val {
- font-feature-settings: "tnum" 1, "zero" 1;
-}
diff --git a/apps/web/public/app/vendor-data.jsx b/apps/web/public/app/vendor-data.jsx
deleted file mode 100644
index 27ee555..0000000
--- a/apps/web/public/app/vendor-data.jsx
+++ /dev/null
@@ -1,578 +0,0 @@
-// vendor-data.jsx — central registry for every vendor in the demo.
-// Each vendor record carries everything the portfolio / change / evidence
-// screens need to render. Notion stays the interactive hero flow
-// (P1 → escalate → ROUTED). Other vendors are pre-baked in different
-// states (acknowledged, snoozed, healthy) and render as read-only.
-
-const VENDOR_DATA = {
-
- // ─────────────────────────────────────────────────────────
- // NOTION — the hero flow (P1, interactive escalate)
- // ─────────────────────────────────────────────────────────
- notion: {
- key: "notion",
- letter: "N",
- name: "Notion",
- category: "DOCS · COLLAB",
- meta: "DOCS · TIER 1",
- tier: 1,
- sev: "p1",
- sevLabel: "P1",
- owner: { initials: "PN", cls: "b", name: "Priya Natarajan", role: "LEGAL COUNSEL" },
- secondary: { initials: "MC", cls: "tangerine", name: "Marcus Chen", role: "PROCUREMENT" },
- annual: "$184,200",
- annualUsd: 184200,
- seats: 847,
- renewsInDays: 42,
- renewsLabel: "42 DAYS",
- renewsCls: "warn",
- dataClasses: ["pii", "source"],
- interactive: true,
- lastChange: { label: "17m ago · DATA · PRICING", cls: "crit" },
- activity: {
- sev: "p1", when: "17m",
- title: "Data retention shrinks 90→30d, per-seat +18%",
- meta: "DATA · PRICING", impact: "+$28.4k/yr", impactCls: "in",
- },
- cr: {
- bundleId: "RL·4839",
- categories: ["DATA", "PRICING"],
- titleOpen: <>Data retention
shrinks from 90 to 30 days. Per-seat pricing rises 18%.>,
- titleRouted: <>Routed to Legal. Negotiation packet
witnessed and signed.>,
- detectedAt: "2026-05-22 · 14:42:18 EST",
- detectedWhen: "17 minutes ago",
- routedAt: "2026-05-22 · 14:59 EST",
- routedWhen: "just now",
- agent: "agent-redline-v1.4",
- citationCount: 4,
- impacts: [
- { lbl: "$ Impact · annual", val: "+$28,400", cls: "dollar" },
- { lbl: "Per-seat change", val: "+18% / 30d", cls: "delta" },
- { lbl: "Compliance", val: "GDPR · Art 5(1)e", cls: "compl" },
- ],
- diffs: [
- {
- label: "Change · §4.2 Data Retention",
- before: {
- when: "SNAPSHOT · MAR 18 2026",
- text: <>"Workspace owners may request export and deletion of all user content at any time.
- Notion will retain backup copies for
ninety (90) days following deletion,
- after which content is permanently erased from all systems.">,
- source: "SOURCE · notion.so/terms#4.2 · FETCHED 2026-03-18 09:14 UTC · HASH a3f9…d21c",
- },
- after: {
- when: "SNAPSHOT · MAY 22 2026",
- text: <>"Workspace owners may request export and deletion of all user content at any time.
- Notion will retain backup copies for
thirty (30) days following deletion,
- after which content is permanently erased from all systems.">,
- sourceLink: "notion.so/terms#4.2",
- sourceMeta: " · FETCHED 2026-05-22 14:42 UTC · HASH 8b2e…f94a",
- },
- },
- {
- label: "Change · §7.1 Plus Plan Pricing",
- before: {
- when: "SNAPSHOT · MAR 18 2026",
- text: <>"Plus plan:
$10 per member per month, billed annually.
- Includes unlimited blocks, file uploads up to 5 GB, and 30-day version history.">,
- source: "SOURCE · notion.so/pricing · FETCHED 2026-03-18 09:14 UTC",
- },
- after: {
- when: "SNAPSHOT · MAY 22 2026",
- text: <>"Plus plan:
$11.80 per member per month, billed annually.
- Includes unlimited blocks, file uploads up to 5 GB, and 30-day version history.">,
- sourceLink: "notion.so/pricing",
- sourceMeta: " · FETCHED 2026-05-22 14:42 UTC",
- },
- },
- ],
- policy: {
- head: "Policy fired",
- name: "Price ↑ >10% within 90d of renewal → P1 to Procurement",
- meta: "AUTHOR · Maya A. · GRC LEAD · v4 · 2026-04-12",
- yamlKey: "pricing",
- },
- actions: [
- { type: "slack", target: "DM · @priya", queued: "queued · awaits escalation", sent: "14:59:02 · delivered" },
- { type: "jira", target: "PROC-2104", queued: "queued", sent: "14:59:03 · assigned" },
- { type: "cal", target: "Renewal call · Jun 24", queued: "queued", sent: "14:59:04 · 4 invitees" },
- { type: "email", target: "Renego draft · ready", queued: "queued", sent: "14:59:05 · 3 versions" },
- ],
- recoOpen: {
- head: "Recommendation",
- text: <>Renewal is in
42 days. The compounded retention shrinkage and price
- increase are
both negotiable at this stage. Generate the renegotiation packet
- to push back on retention to 60d as a minimum (industry standard), and lock pricing at
- the prior $10/seat for the renewal term. Estimated leverage:
$28,400/yr saved on
- price alone; retention recovery maps to
SOC2 CC6.5 and
GDPR Art 5(1)e.>,
- },
- recoRouted: {
- head: "Outcome · Routed",
- text: <>Routed to
Priya Natarajan (Legal) and
Marcus Chen (Procurement). The
- renegotiation packet has been
generated and witnessed — see Bundle RL·4839.
- 4 actions dispatched: Slack DM, Jira PROC-2104, calendar invite for Jun 24, draft email
- with three negotiation positions. Deadline: 2026-05-29.>,
- },
- },
- bundle: {
- id: "RL·4839",
- seal: "US",
- signedAt: "2026-05-22 · 14:59:08 EST",
- hash: "8b2e…f94a",
- eyebrow: "NOTION · CR · DATA + PRICING",
- coverSubtitle: <>Notion · data retention & pricing change ·
witnessed>,
- sectionCount: 4,
- miniDiffs: [
- [
- { kind: "b", text: <>"…will retain backup copies for
ninety (90) days following deletion…">, src: "SOURCE · notion.so/terms#4.2 · 2026-03-18 · HASH a3f9…d21c" },
- { kind: "a", text: <>"…will retain backup copies for
thirty (30) days following deletion…">, src: "SOURCE · notion.so/terms#4.2 · 2026-05-22 · HASH 8b2e…f94a" },
- ],
- [
- { kind: "b", text: <>"Plus plan:
$10 per member per month, billed annually.">, src: "SOURCE · notion.so/pricing · 2026-03-18" },
- { kind: "a", text: <>"Plus plan:
$11.80 per member per month, billed annually.">, src: "SOURCE · notion.so/pricing · 2026-05-22" },
- ],
- ],
- policyBody: <>
severity-rules.yaml v4 · "Price ↑ >10% within 90d of renewal → P1 to Procurement."
- Notion renewal is in
42 days; per-seat change is
+18%. The policy clause
- matched at
2026-05-22 14:42:21 EST and was queued for routing.>,
- trail: [
- { ts: "14:59:01", msg: <>Policy fired ·
severity-rules.yaml v4>, status: "✓ MATCH" },
- { ts: "14:59:02", msg: <>Slack DM ·
@priya · delivered>, status: "✓ SENT" },
- { ts: "14:59:03", msg: <>Jira ·
PROC-2104 · assigned Marcus Chen>, status: "✓ OPEN" },
- { ts: "14:59:04", msg: <>Calendar ·
Jun 24 renewal call · 4 invitees>, status: "✓ SCHED" },
- { ts: "14:59:05", msg: <>Drafts ·
renegotiation packet · 3 positions>, status: "✓ READY" },
- { ts: "14:59:08", msg: <>Bundle signed · hash
8b2e…f94a>, status: "✓ WITNESSED" },
- ],
- citations: [
- { src: "notion.so/terms#4.2 · §4.2 Data Retention · public", hash: "8b2e…f94a" },
- { src: "notion.so/pricing · §7.1 Plus Plan · public", hash: "d4a1…2c7b" },
- { src: "internal · DPA §3.1 (Acme ⟷ Notion) · private", hash: "f9c3…81de" },
- { src: "policy · severity-rules.yaml v4 · internal", hash: "a3f9…d21c" },
- ],
- nextActions: [
- { type: "cal", target: "Renewal call", when: "Jun 24 · 14:00 EST", status: "SCHED", pending: false },
- { type: "email", target: "Send renego draft", when: "Owner · Priya N.", status: "DUE 5/29", pending: true },
- ],
- },
- routingTitle: <>Routed to
Priya. Bundle
RL·4839 generating…>,
- routingLog: [
- { ts: "14:59:01", lvl: "info", msg: <>Policy fired ·
severity-rules.yaml v4 · severity P1> },
- { ts: "14:59:02", lvl: "exec", msg: <>Slack DM dispatched ·
@priya · message + 4 citations> },
- { ts: "14:59:03", lvl: "exec", msg: <>Jira ticket created ·
PROC-2104 · assigned to Marcus Chen> },
- { ts: "14:59:04", lvl: "exec", msg: <>Calendar invite sent ·
Jun 24 · Notion renewal call · 4 invitees> },
- { ts: "14:59:05", lvl: "exec", msg: <>Renegotiation draft generated ·
3 positions · saved to Drafts> },
- { ts: "14:59:06", lvl: "info", msg: <>Compiling evidence bundle
RL·4839 · 4 grounded citations> },
- { ts: "14:59:07", lvl: "sign", msg: <>Clauses extracted · §4.2 Retention · §7.1 Pricing · DPA §3.1 · policy> },
- { ts: "14:59:08", lvl: "sign", msg: <>Bundle signed · hash
8b2e…f94a · witnessed by agent-redline-v1.4> },
- { ts: "14:59:09", lvl: "ok", msg: <>Bundle
RL·4839 written · grounded · routed · ready> },
- ],
- },
-
- // ─────────────────────────────────────────────────────────
- // DATADOG — P1, already acknowledged, bundle witnessed
- // ─────────────────────────────────────────────────────────
- datadog: {
- key: "datadog",
- letter: "D",
- name: "Datadog",
- category: "OBSERVABILITY · MONITORING",
- meta: "OBSERVABILITY · TIER 1",
- tier: 1,
- sev: "p1",
- sevLabel: "P1",
- owner: { initials: "RK", cls: "a", name: "Ravi Krishnan", role: "SECURITY LEAD" },
- secondary: { initials: "AO", cls: "grape", name: "Ada Owens", role: "PRIVACY OFFICER" },
- annual: "$420,000",
- annualUsd: 420000,
- seats: 312,
- renewsInDays: 11,
- renewsLabel: "11 DAYS",
- renewsCls: "warn",
- dataClasses: ["pii", "phi"],
- mode: "acknowledged",
- interactive: false,
- lastChange: { label: "2h ago · SUB-PROC", cls: "crit" },
- activity: {
- sev: "p1", when: "2h",
- title: "Sub-processor added · Tencent Cloud · CN",
- meta: "SUB-PROC · JURISDICTION", impact: "+30d NOTICE", impactCls: "",
- },
- cr: {
- bundleId: "RL·4812",
- categories: ["SUB-PROC", "JURISDICTION"],
- titleOpen: <>New sub-processor
Tencent Cloud (CN) added to data path. Non-adequate jurisdiction.>,
- detectedAt: "2026-05-22 · 12:18:04 EST",
- detectedWhen: "2 hours ago",
- acknowledgedAt: "2026-05-22 · 12:51 EST",
- acknowledgedWhen: "1h 51m ago",
- agent: "agent-redline-v1.4",
- citationCount: 3,
- impacts: [
- { lbl: "Jurisdiction", val: "CN · non-adequate", cls: "dollar" },
- { lbl: "Customer notice", val: "+30d required", cls: "delta" },
- { lbl: "Compliance", val: "GDPR · Art 28(2)", cls: "compl" },
- ],
- diffs: [
- {
- label: "Change · Sub-processor list · APAC region",
- before: {
- when: "SNAPSHOT · APR 14 2026",
- text: <>"Datadog uses the following sub-processors in APAC:
AWS (ap-northeast-1),
-
GCP (asia-east1). No data routed through PRC-based providers.">,
- source: "SOURCE · datadog.com/legal/sub-processors · FETCHED 2026-04-14 06:02 UTC · HASH 71be…0caf",
- },
- after: {
- when: "SNAPSHOT · MAY 22 2026",
- text: <>"Datadog uses the following sub-processors in APAC: AWS (ap-northeast-1),
- GCP (asia-east1), and
Tencent Cloud (cn-shanghai) for traffic-origin
- metrics retention in the Greater China region.">,
- sourceLink: "datadog.com/legal/sub-processors",
- sourceMeta: " · FETCHED 2026-05-22 12:18 UTC · HASH 4d09…b2e1",
- },
- },
- ],
- policy: {
- head: "Policy fired",
- name: "Sub-processor added in non-adequate jurisdiction → P1 to Privacy",
- meta: "AUTHOR · Marcus Chen · SECURITY · v1 · 2026-01-08",
- yamlKey: "subprocessor",
- },
- actions: [
- { type: "slack", target: "#privacy", queued: "queued", sent: "12:18:42 · 6 members" },
- { type: "jira", target: "PRIV-1188", queued: "queued", sent: "12:18:44 · Ada Owens" },
- { type: "email", target: "Customer notice · draft", queued: "queued", sent: "12:18:46 · 30d countdown" },
- ],
- recoOpen: {
- head: "Recommendation",
- text: <>Tencent Cloud is in a
non-adequate jurisdiction under GDPR. A
30-day customer
- notice is required before the sub-processor goes live. Privacy team should evaluate
- transfer mechanism (SCCs + supplementary measures) and confirm data classes routed (PHI must
- not be eligible). Renewal in
11 days — block renewal until DPA addendum signed.>,
- },
- recoRouted: {
- head: "Outcome · Acknowledged",
- text: <>Privacy team acknowledged at
12:51 EST. Customer-notice draft attached to
-
PRIV-1188. Transfer mechanism review scheduled for
2026-05-25.
- Renewal of
$420k contract held until DPA addendum signature.>,
- },
- },
- bundle: {
- id: "RL·4812",
- seal: "US",
- signedAt: "2026-05-22 · 12:51:33 EST",
- hash: "4d09…b2e1",
- eyebrow: "DATADOG · CR · SUB-PROCESSOR",
- coverSubtitle: <>Datadog · new sub-processor (Tencent Cloud · CN) ·
acknowledged>,
- sectionCount: 4,
- miniDiffs: [
- [
- { kind: "b", text: <>"…APAC sub-processors: AWS, GCP.
No PRC-based providers…">, src: "SOURCE · datadog.com/legal/sub-processors · 2026-04-14 · HASH 71be…0caf" },
- { kind: "a", text: <>"…APAC sub-processors: AWS, GCP, and
Tencent Cloud (cn-shanghai)…">, src: "SOURCE · datadog.com/legal/sub-processors · 2026-05-22 · HASH 4d09…b2e1" },
- ],
- ],
- policyBody: <>
severity-rules.yaml v4 · "Sub-processor added in non-adequate jurisdiction → P1 to Privacy."
- Datadog added Tencent Cloud (CN-Shanghai). The policy matched at
-
2026-05-22 12:18:09 EST and queued a customer-notice draft.>,
- trail: [
- { ts: "12:18:09", msg: <>Policy fired ·
subprocessor.yaml v1>, status: "✓ MATCH" },
- { ts: "12:18:42", msg: <>Slack channel ·
#privacy · 6 members notified>, status: "✓ SENT" },
- { ts: "12:18:44", msg: <>Jira ·
PRIV-1188 · assigned Ada Owens>, status: "✓ OPEN" },
- { ts: "12:18:46", msg: <>Draft ·
30-day customer notice>, status: "✓ READY" },
- { ts: "12:51:30", msg: <>Acknowledged by Ada Owens ·
transfer review 5/25>, status: "✓ ACK" },
- { ts: "12:51:33", msg: <>Bundle signed · hash
4d09…b2e1>, status: "✓ WITNESSED" },
- ],
- citations: [
- { src: "datadog.com/legal/sub-processors · APAC list · public", hash: "4d09…b2e1" },
- { src: "internal · DPA §6 (Acme ⟷ Datadog) · private", hash: "71be…0caf" },
- { src: "policy · subprocessor.yaml v1 · internal", hash: "c2a8…59f0" },
- ],
- nextActions: [
- { type: "cal", target: "Transfer review", when: "May 25 · 10:00 EST", status: "SCHED", pending: false },
- { type: "email", target: "Customer notice", when: "Send by Jun 21", status: "DUE 6/21", pending: true },
- ],
- },
- },
-
- // ─────────────────────────────────────────────────────────
- // STRIPE — P2, terms change (liability cap)
- // ─────────────────────────────────────────────────────────
- stripe: {
- key: "stripe",
- letter: "S",
- name: "Stripe",
- category: "PAYMENTS · BILLING",
- meta: "PAYMENTS · TIER 1",
- tier: 1,
- sev: "p2",
- sevLabel: "P2",
- owner: { initials: "MA", cls: "c", name: "Maya Ahmadi", role: "GRC LEAD" },
- secondary: { initials: "LP", cls: "lime", name: "Lin Park", role: "LEGAL COUNSEL" },
- annual: "$2.4M",
- annualUsd: 2400000,
- seats: 20,
- renewsInDays: 244,
- renewsLabel: "8 MONTHS",
- renewsCls: "",
- dataClasses: ["pii", "payments"],
- mode: "open",
- interactive: false,
- lastChange: { label: "6h ago · TERMS", cls: "" },
- activity: {
- sev: "p2", when: "6h",
- title: "Liability cap reduced from $1M to $500k",
- meta: "TERMS · LIABILITY", impact: "LEGAL REVIEW", impactCls: "",
- },
- cr: {
- bundleId: "RL·4798",
- categories: ["TERMS", "LIABILITY"],
- titleOpen: <>Liability cap
halved from $1M to $500k. Indemnification scope narrowed.>,
- detectedAt: "2026-05-22 · 08:31:17 EST",
- detectedWhen: "6 hours ago",
- agent: "agent-redline-v1.4",
- citationCount: 3,
- impacts: [
- { lbl: "Liability cap", val: "−$500,000", cls: "dollar" },
- { lbl: "Indemnification", val: "Narrowed scope", cls: "delta" },
- { lbl: "Renewal", val: "8 months out", cls: "compl" },
- ],
- diffs: [
- {
- label: "Change · §11.3 Limitation of Liability",
- before: {
- when: "SNAPSHOT · FEB 02 2026",
- text: <>"Stripe's aggregate liability under this agreement shall not exceed
-
one million dollars ($1,000,000) or the fees paid by Customer
- in the preceding twelve (12) months, whichever is greater.">,
- source: "SOURCE · stripe.com/legal#11.3 · FETCHED 2026-02-02 11:08 UTC · HASH 9f2c…7b4d",
- },
- after: {
- when: "SNAPSHOT · MAY 22 2026",
- text: <>"Stripe's aggregate liability under this agreement shall not exceed
-
five hundred thousand dollars ($500,000) or the fees paid by Customer
- in the preceding twelve (12) months, whichever is greater.">,
- sourceLink: "stripe.com/legal#11.3",
- sourceMeta: " · FETCHED 2026-05-22 08:31 UTC · HASH 6a13…8e29",
- },
- },
- ],
- policy: {
- head: "Policy fired",
- name: "Liability cap reduced >25% → P2 to Legal",
- meta: "AUTHOR · Lin Park · LEGAL · v2 · 2026-03-04",
- yamlKey: "terms",
- },
- actions: [
- { type: "slack", target: "DM · @maya", queued: "queued · awaits ack", sent: "—" },
- { type: "jira", target: "LEGAL-0942", queued: "queued", sent: "—" },
- ],
- recoOpen: {
- head: "Recommendation",
- text: <>Stripe's standard cap is now
50% lower. Acme processes
$2.4M/yr
- through Stripe; current cap is materially under-protective vs. annual volume. Renewal is
- 8 months out — leverage MSA negotiation to
restore $1M cap or negotiate a custom carve-out.
- No immediate action required; flag for renewal-prep cycle.>,
- },
- },
- bundle: null,
- },
-
- // ─────────────────────────────────────────────────────────
- // AWS — P2, pricing increase on hot SKU
- // ─────────────────────────────────────────────────────────
- aws: {
- key: "aws",
- letter: "A",
- name: "AWS",
- category: "CLOUD · INFRASTRUCTURE",
- meta: "INFRA · TIER 1",
- tier: 1,
- sev: "p2",
- sevLabel: "P2",
- owner: { initials: "RK", cls: "a", name: "Ravi Krishnan", role: "PLATFORM LEAD" },
- secondary: { initials: "JT", cls: "d", name: "Jordan Tao", role: "FINOPS" },
- annual: "$1.8M",
- annualUsd: 1800000,
- seats: 0,
- renewsInDays: 92,
- renewsLabel: "3 MONTHS",
- renewsCls: "",
- dataClasses: ["pii", "source"],
- mode: "open",
- interactive: false,
- lastChange: { label: "1d ago · PRICING", cls: "" },
- activity: {
- sev: "p2", when: "1d",
- title: "EC2 g6.xlarge +8% in us-east-1",
- meta: "PRICING", impact: "+$14.2k/yr", impactCls: "in",
- },
- cr: {
- bundleId: "RL·4761",
- categories: ["PRICING"],
- titleOpen: <>EC2
g6.xlarge hourly rate up
8% in us-east-1. Acme runs 47 nodes.>,
- detectedAt: "2026-05-21 · 14:42:00 EST",
- detectedWhen: "1 day ago",
- agent: "agent-redline-v1.4",
- citationCount: 2,
- impacts: [
- { lbl: "$ Impact · annual", val: "+$14,200", cls: "dollar" },
- { lbl: "Per-node change", val: "+$0.094/hr", cls: "delta" },
- { lbl: "Affected nodes", val: "47 · ML inference", cls: "compl" },
- ],
- diffs: [
- {
- label: "Change · EC2 g6.xlarge · us-east-1",
- before: {
- when: "SNAPSHOT · MAY 15 2026",
- text: <>"g6.xlarge (1× L4 GPU, 4 vCPU, 16 GiB) ·
$1.174 per hour · on-demand · us-east-1.">,
- source: "SOURCE · aws.amazon.com/ec2/instance-types · FETCHED 2026-05-15 09:00 UTC · HASH 3c80…d471",
- },
- after: {
- when: "SNAPSHOT · MAY 21 2026",
- text: <>"g6.xlarge (1× L4 GPU, 4 vCPU, 16 GiB) ·
$1.268 per hour · on-demand · us-east-1.">,
- sourceLink: "aws.amazon.com/ec2/instance-types",
- sourceMeta: " · FETCHED 2026-05-21 14:42 UTC · HASH a09f…1c3b",
- },
- },
- ],
- policy: {
- head: "Policy fired",
- name: "SKU pricing increase >5% → P2 to FinOps",
- meta: "AUTHOR · Jordan Tao · FINOPS · v3 · 2026-02-18",
- yamlKey: "pricing",
- },
- actions: [
- { type: "slack", target: "DM · @jordan", queued: "queued · awaits ack", sent: "—" },
- { type: "jira", target: "FIN-0322", queued: "queued", sent: "—" },
- ],
- recoOpen: {
- head: "Recommendation",
- text: <>g6.xlarge powers Acme's ML inference fleet (47 nodes). Annualized impact:
+$14.2k.
- FinOps should evaluate
3-year savings plan at the older rate (auto-applies if signed
- before 2026-08-22 renewal) or migrate inference to
g5.xlarge (−12% perf, −18% cost).>,
- },
- },
- bundle: null,
- },
-
- // ─────────────────────────────────────────────────────────
- // LINEAR — Healthy, no recent diff
- // ─────────────────────────────────────────────────────────
- linear: {
- key: "linear",
- letter: "L",
- name: "Linear",
- category: "PROJECT · COLLAB",
- meta: "PROJECT · TIER 2",
- tier: 2,
- sev: "healthy",
- sevLabel: "OK",
- owner: { initials: "JT", cls: "d", name: "Jordan Tao", role: "OWNER" },
- annual: "$48,000",
- annualUsd: 48000,
- seats: 240,
- renewsInDays: 153,
- renewsLabel: "5 MONTHS",
- renewsCls: "",
- dataClasses: ["pii"],
- mode: "healthy",
- interactive: false,
- lastChange: { label: "14 days · no diff", cls: "" },
- activity: null,
- cr: {
- categories: ["NO CHANGE"],
- titleOpen: <>No material changes detected in the last
14 days. Posture stable.>,
- detectedAt: "2026-05-22 · 14:42:18 EST",
- detectedWhen: "14 days since last scan diff",
- agent: "agent-redline-v1.4",
- citationCount: 0,
- lastScannedSurfaces: [
- { url: "linear.app/terms", when: "2026-05-22 14:38 UTC", hash: "stable · e7d2…c918" },
- { url: "linear.app/dpa", when: "2026-05-22 14:38 UTC", hash: "stable · 819a…0042" },
- { url: "linear.app/security", when: "2026-05-22 14:39 UTC", hash: "stable · 4f0c…dd80" },
- { url: "linear.app/sub-processors", when: "2026-05-22 14:39 UTC", hash: "stable · 6b22…aaef" },
- ],
- recoOpen: {
- head: "Status · Healthy",
- text: <>All
4 monitored surfaces match prior snapshots. Last meaningful change
- was on
2026-05-08 (security headers update, non-material). Renewal in
-
5 months — no early action needed.>,
- },
- },
- bundle: null,
- },
-
- // ─────────────────────────────────────────────────────────
- // FIGMA — Healthy, no recent diff
- // ─────────────────────────────────────────────────────────
- figma: {
- key: "figma",
- letter: "F",
- name: "Figma",
- category: "DESIGN · COLLAB",
- meta: "DESIGN · TIER 2",
- tier: 2,
- sev: "healthy",
- sevLabel: "OK",
- owner: { initials: "JT", cls: "d", name: "Jordan Tao", role: "OWNER" },
- annual: "$96,000",
- annualUsd: 96000,
- seats: 480,
- renewsInDays: 214,
- renewsLabel: "7 MONTHS",
- renewsCls: "",
- dataClasses: ["pii"],
- mode: "healthy",
- interactive: false,
- lastChange: { label: "22 days · no diff", cls: "" },
- activity: null,
- cr: {
- categories: ["NO CHANGE"],
- titleOpen: <>No material changes detected in the last
22 days. Posture stable.>,
- detectedAt: "2026-05-22 · 14:42:18 EST",
- detectedWhen: "22 days since last scan diff",
- agent: "agent-redline-v1.4",
- citationCount: 0,
- lastScannedSurfaces: [
- { url: "figma.com/legal/terms", when: "2026-05-22 14:35 UTC", hash: "stable · 1eaa…58c2" },
- { url: "figma.com/legal/dpa", when: "2026-05-22 14:36 UTC", hash: "stable · 0c44…b1f9" },
- { url: "figma.com/security", when: "2026-05-22 14:36 UTC", hash: "stable · d8e7…7710" },
- { url: "figma.com/legal/sub-processors", when: "2026-05-22 14:37 UTC", hash: "stable · 33ab…ee21" },
- ],
- recoOpen: {
- head: "Status · Healthy",
- text: <>All
4 monitored surfaces match prior snapshots. Last meaningful change
- was on
2026-04-30 (pricing FAQ refresh, non-material). Renewal in
-
7 months — no early action needed.>,
- },
- },
- bundle: null,
- },
-
-};
-
-// Per-vendor YAML snippet used in the policy panel. Kept terse so it fits.
-const POLICY_YAML = {
- pricing: (
-
-{`# severity-rules.yaml`}
-{"\n"}when:{"\n "}change.category: "pricing"{"\n "}change.dollarImpact.pct: ">10"{"\n "}vendor.renewsAt: "within 90d"{"\n"}then:{"\n "}severity: P1{"\n "}route:{"\n - "}slack:@priya{"\n - "}jira:PROC{"\n - "}copilot:renegotiate
-
- ),
- subprocessor: (
-
-{`# severity-rules.yaml`}
-{"\n"}when:{"\n "}change.category: "subprocessor"{"\n "}new.jurisdiction.adequate: "false"{"\n"}then:{"\n "}severity: P1{"\n "}route:{"\n - "}slack:#privacy{"\n - "}jira:PRIV{"\n - "}draft:customer-notice-30d
-
- ),
- terms: (
-
-{`# severity-rules.yaml`}
-{"\n"}when:{"\n "}change.category: "terms"{"\n "}clause.liabilityCap.pctDelta: {'"<= -25"'}{"\n"}then:{"\n "}severity: P2{"\n "}route:{"\n - "}slack:@maya{"\n - "}jira:LEGAL
-
- ),
-};
-
-// Order used by the portfolio grid + the order the activity feed cycles.
-const VENDOR_ORDER = ["notion", "datadog", "stripe", "aws", "linear", "figma"];
-
-Object.assign(window, { VENDOR_DATA, POLICY_YAML, VENDOR_ORDER });
diff --git a/apps/web/public/unsyphn-mark.png b/apps/web/public/unsyphn-mark.png
new file mode 100644
index 0000000..29b6a08
Binary files /dev/null and b/apps/web/public/unsyphn-mark.png differ
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 919ba13..62cd5c6 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -1,110 +1,310 @@
import { useEffect, useState } from "react";
-import { Onboard } from "./screens/Onboard.js";
-import { StripeModal } from "./screens/StripeModal.js";
+import { Command } from "lucide-react";
import { SensoBrief } from "./screens/SensoBrief.js";
-import { VendorOnboarding } from "./screens/VendorOnboarding.js";
+import { Portfolio } from "./screens/Portfolio.js";
+import { ChangeReport } from "./screens/ChangeReport.js";
+import { Onboarding } from "./screens/Onboarding.js";
+import { CommandPalette } from "./components/CommandPalette.js";
+import { Inbox } from "./screens/Inbox.js";
+import { VendorDetail } from "./screens/VendorDetail.js";
+import { Requests } from "./screens/Requests.js";
+import { Renewals } from "./screens/Renewals.js";
+import { Findings } from "./screens/Findings.js";
+import { Reports } from "./screens/Reports.js";
+import { Pricing } from "./screens/Pricing.js";
+import { AuditorMode } from "./screens/AuditorMode.js";
+import { Settings } from "./screens/Settings.js";
+import { TrustCenter } from "./screens/TrustCenter.js";
+import { Demo } from "./screens/Demo.js";
+import { Contact } from "./screens/Contact.js";
+import { Privacy } from "./screens/Privacy.js";
+import { Terms } from "./screens/Terms.js";
+import { Docs } from "./screens/Docs.js";
+
+function VendorDetailPlaceholder(): JSX.Element {
+ return (
+
+ Vendor Detail
+ Vendor detail coming in W4.
+
+ );
+}
+
+
+function PoliciesPlaceholder(): JSX.Element {
+ return (
+
+ Policies
+ Policy Studio coming soon.
+
+ );
+}
+
+function SettingsPlaceholder(): JSX.Element {
+ return (
+
+ Settings
+ Settings coming in W7.
+
+ );
+}
+
+function isAppRoute(pathname: string): boolean {
+ return pathname === "/app" || pathname.startsWith("/app/");
+}
function parseEvidenceId(pathname: string): string | undefined {
- const match = pathname.match(/^\/evidence\/([^/?#]+)\/?$/);
+ const match = pathname.match(/^\/app\/evidence\/([^/?#]+)\/?$/);
return match?.[1];
}
-function isOnboardingRoute(pathname: string): boolean {
- return /^\/onboarding\/?$/.test(pathname);
+function parseVendorId(pathname: string): string | undefined {
+ const match = pathname.match(/^\/app\/vendors\/([^/?#]+)\/?$/);
+ return match?.[1];
}
-function isDashboardRoute(pathname: string): boolean {
- return /^\/dashboard\/?$/.test(pathname);
+function parseChangeId(pathname: string): string | undefined {
+ const match = pathname.match(/^\/app\/change\/([^/?#]+)\/?$/);
+ return match?.[1];
+}
+
+type ActiveNav =
+ | "inbox"
+ | "vendors"
+ | "renewals"
+ | "requests"
+ | "findings"
+ | "reports"
+ | "settings"
+ | null;
+
+function currentNav(pathname: string): ActiveNav {
+ if (pathname === "/app" || pathname === "/app/" || pathname.startsWith("/app/inbox")) return "inbox";
+ if (pathname.startsWith("/app/vendors")) return "vendors";
+ if (pathname.startsWith("/app/renewals")) return "renewals";
+ if (pathname.startsWith("/app/requests")) return "requests";
+ if (pathname.startsWith("/app/findings")) return "findings";
+ if (pathname.startsWith("/app/reports")) return "reports";
+ if (pathname.startsWith("/app/settings")) return "settings";
+ return null;
}
export function App(): JSX.Element | null {
const pathname =
typeof window !== "undefined" ? window.location.pathname : "/";
- const isAppRoute =
- isOnboardingRoute(pathname) ||
- isDashboardRoute(pathname) ||
- Boolean(parseEvidenceId(pathname));
+
+ const inApp = isAppRoute(pathname);
useEffect(() => {
- if (isAppRoute) {
+ document.title = "Unsyphn";
+ }, []);
+
+ useEffect(() => {
+ if (inApp) {
document.body.classList.add("app-mode");
+ } else {
+ document.body.classList.remove("app-mode");
}
return () => {
document.body.classList.remove("app-mode");
};
- }, [isAppRoute]);
+ }, [inApp]);
- const evidenceId = parseEvidenceId(pathname);
- if (evidenceId) {
- return
;
- }
-
- if (isOnboardingRoute(pathname)) {
- return
;
- }
-
- if (isDashboardRoute(pathname)) {
- const tier = typeof window !== "undefined"
- ? new URLSearchParams(window.location.search).get("tier")
- : null;
- const parsed = tier ? parseInt(tier, 10) : NaN;
- const tierNum = parsed === 1 || parsed === 2 || parsed === 3 ? parsed : undefined;
- return
;
- }
+ useEffect(() => {
+ if (!inApp) return;
+ if (
+ typeof window.matchMedia === "function" &&
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ ) {
+ return;
+ }
+ let raf = 0;
+ const handler = (e: PointerEvent): void => {
+ const x = e.clientX / window.innerWidth;
+ const y = e.clientY / window.innerHeight;
+ cancelAnimationFrame(raf);
+ raf = requestAnimationFrame(() => {
+ document.documentElement.style.setProperty("--cursor-x", String(x));
+ document.documentElement.style.setProperty("--cursor-y", String(y));
+ });
+ };
+ window.addEventListener("pointermove", handler, { passive: true });
+ return () => {
+ window.removeEventListener("pointermove", handler);
+ cancelAnimationFrame(raf);
+ document.documentElement.style.removeProperty("--cursor-x");
+ document.documentElement.style.removeProperty("--cursor-y");
+ };
+ }, [inApp]);
- return null;
-}
+ if (pathname === "/pricing") return
;
+ if (pathname === "/trust") return
;
+ if (pathname === "/demo") return
;
+ if (pathname === "/contact") return
;
+ if (pathname === "/privacy") return
;
+ if (pathname === "/terms") return
;
+ if (pathname === "/docs") return
;
+ const auditorMatch = pathname.match(/^\/auditor\/([^/?#]+)\/?$/);
+ if (auditorMatch?.[1]) return
;
+ if (!inApp) return null;
-interface DashboardAppProps {
- prefillTier?: 1 | 2 | 3;
-}
+ const evidenceId = parseEvidenceId(pathname);
+ const vendorId = parseVendorId(pathname);
+ const changeId = parseChangeId(pathname);
+ const activeNav = currentNav(pathname);
-function DashboardApp({ prefillTier }: DashboardAppProps): JSX.Element {
- const [showUpgrade, setShowUpgrade] = useState(false);
return (
-
-
-
-
-
-
-
- {showUpgrade && setShowUpgrade(false)} />}
-
+ <>
+
+
+ {evidenceId ? (
+
+ ) : pathname === "/app" || pathname === "/app/" || pathname.startsWith("/app/inbox") ? (
+
+ ) : vendorId ? (
+
+ ) : pathname.startsWith("/app/vendors") ? (
+
+ ) : changeId ? (
+
+ ) : pathname.startsWith("/app/renewals") ? (
+
+ ) : pathname.startsWith("/app/requests") ? (
+
+ ) : pathname.startsWith("/app/findings") ? (
+
+ ) : pathname.startsWith("/app/reports") ? (
+
+ ) : pathname.startsWith("/app/policies") ? (
+
+ ) : pathname.startsWith("/app/onboarding") ? (
+
+ ) : pathname.startsWith("/app/settings") ? (
+
+ ) : null}
+
+
+ >
);
}
-function VendorOnboardingApp(): JSX.Element {
+interface TopBarProps {
+ activeNav: ActiveNav;
+}
+
+function TopBar({ activeNav }: TopBarProps): JSX.Element {
+ const [scrolled, setScrolled] = useState(false);
+
+ useEffect(() => {
+ const onScroll = (): void => setScrolled(window.scrollY > 8);
+ onScroll();
+ window.addEventListener("scroll", onScroll, { passive: true });
+ return () => window.removeEventListener("scroll", onScroll);
+ }, []);
+
return (
-
-
-
-
+ Unsyphn
+
+
+
+
+
+
+
);
}
diff --git a/apps/web/src/components/BulkActionBar.tsx b/apps/web/src/components/BulkActionBar.tsx
new file mode 100644
index 0000000..e6c12e8
--- /dev/null
+++ b/apps/web/src/components/BulkActionBar.tsx
@@ -0,0 +1,204 @@
+import { useState } from "react";
+import { CheckCircle2, Clock, Archive, ArrowUpRight, X } from "lucide-react";
+
+interface Props {
+ count: number;
+ onMarkRead: () => void;
+ onSnooze: (untilIso: string) => void;
+ onResolve: () => void;
+ onEscalate: () => void;
+ onClear: () => void;
+}
+
+function plus48hIso(): string {
+ return new Date(Date.now() + 48 * 3600 * 1000).toISOString();
+}
+
+function plus1wIso(): string {
+ return new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString();
+}
+
+export function BulkActionBar({
+ count,
+ onMarkRead,
+ onSnooze,
+ onResolve,
+ onEscalate,
+ onClear,
+}: Props): JSX.Element {
+ const [snoozeOpen, setSnoozeOpen] = useState(false);
+
+ return (
+
+
+
+
+ {count} selected
+
+
+
+
+
+
+ Mark read
+
+
+
+
setSnoozeOpen((v) => !v)} aria-expanded={snoozeOpen}>
+
+ Snooze
+
+ {snoozeOpen && (
+
+ {
+ setSnoozeOpen(false);
+ onSnooze(plus48hIso());
+ }}
+ />
+ {
+ setSnoozeOpen(false);
+ onSnooze(plus1wIso());
+ }}
+ />
+
+ )}
+
+
+
+
+ Resolve
+
+
+
+
+ Escalate
+
+
+ );
+}
+
+function BulkBtn({
+ onClick,
+ children,
+ ...rest
+}: {
+ onClick: () => void;
+ children: React.ReactNode;
+ "aria-expanded"?: boolean;
+}): JSX.Element {
+ return (
+
+ );
+}
+
+function SnoozeOption({ label, onClick }: { label: string; onClick: () => void }): JSX.Element {
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/ChangeDrawer.tsx b/apps/web/src/components/ChangeDrawer.tsx
new file mode 100644
index 0000000..ea3a681
--- /dev/null
+++ b/apps/web/src/components/ChangeDrawer.tsx
@@ -0,0 +1,375 @@
+import { useEffect, useRef, useState, type JSX } from "react";
+import { createPortal } from "react-dom";
+import { X, AlertCircle, CheckCircle, Clock, ArrowUpRight } from "lucide-react";
+import type { InboxItem, EvidenceBriefResponse, Severity } from "@unsyphn/shared";
+import { ApiError, DEMO_BEARER_TOKEN } from "../lib/api.js";
+import { ROLES, ROLE_LABELS, type Role } from "../lib/role.js";
+import { celebrate } from "../lib/confetti.js";
+import { DiffViewer } from "./DiffViewer.js";
+
+export interface ChangeDrawerProps {
+ open: boolean;
+ onClose: () => void;
+ item: InboxItem | null;
+ onEscalated?: (id: string, toRole: Role) => void;
+}
+
+const FOCUSABLE = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
+
+const SEV_BADGE: Record
= {
+ P1: "badge badge-danger",
+ P2: "badge badge-warning",
+ P3: "badge badge-success",
+};
+
+type ToastVariant = "success" | "error";
+type EscalateState = "idle" | "confirming";
+
+async function postChange(path: string, body?: Record): Promise {
+ const headers: Record = { Authorization: `Bearer ${DEMO_BEARER_TOKEN}` };
+ if (body) headers["Content-Type"] = "application/json";
+ const resp = await fetch(path, { method: "POST", headers, body: body ? JSON.stringify(body) : undefined });
+ if (!resp.ok) {
+ const text = await resp.text();
+ const json = text ? (JSON.parse(text) as { error?: { message?: string } }) : undefined;
+ throw new ApiError(resp.status, { error: { code: "request_failed", message: json?.error?.message ?? `HTTP ${resp.status}` } });
+ }
+}
+
+function useFocusTrap(open: boolean, panelRef: React.RefObject, onClose: () => void): void {
+ const openerRef = useRef(null);
+
+ useEffect(() => {
+ if (open) {
+ openerRef.current = document.activeElement;
+ panelRef.current?.querySelectorAll(FOCUSABLE)[0]?.focus();
+ } else {
+ const opener = openerRef.current;
+ if (opener instanceof HTMLElement) opener.focus();
+ openerRef.current = null;
+ }
+ }, [open, panelRef]);
+
+ useEffect(() => {
+ if (!open) return;
+ const onKeyDown = (e: KeyboardEvent): void => {
+ if (e.key === "Escape") { e.preventDefault(); onClose(); return; }
+ if (e.key !== "Tab") return;
+ const panel = panelRef.current;
+ if (!panel) return;
+ const focusable = Array.from(panel.querySelectorAll(FOCUSABLE)).filter((el) => !el.hasAttribute("disabled"));
+ if (!focusable.length) return;
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ if (!first || !last) return;
+ if (e.shiftKey) { if (document.activeElement === first) { e.preventDefault(); last.focus(); } }
+ else { if (document.activeElement === last) { e.preventDefault(); first.focus(); } }
+ };
+ document.addEventListener("keydown", onKeyDown);
+ return () => document.removeEventListener("keydown", onKeyDown);
+ }, [open, panelRef, onClose]);
+}
+
+function SectionLabel({ label, id }: { label: string; id?: string }): JSX.Element {
+ return (
+
+ {label}
+
+ );
+}
+
+function Toast({ message, variant }: { message: string; variant: ToastVariant }): JSX.Element {
+ return (
+
+ {message}
+
+ );
+}
+
+function DrawerBody({ item, evidence, escalate, onEscalate, onSubmitEscalate, escalateBusy, escalateError, escalateRole, escalateNote, onChangeRole, onChangeNote }: {
+ item: InboxItem;
+ evidence: EvidenceBriefResponse | null;
+ escalate: EscalateState;
+ onEscalate: (s: EscalateState) => void;
+ onSubmitEscalate: () => void;
+ escalateBusy: boolean;
+ escalateError: string | null;
+ escalateRole: Role;
+ escalateNote: string;
+ onChangeRole: (r: Role) => void;
+ onChangeNote: (n: string) => void;
+}): JSX.Element {
+ const changes = evidence?.changeReport?.changes ?? [];
+ const isChange = item.kind === "change";
+
+ return (
+
+
+
+
+
+
+ {item.summary}
+ {item.dollarImpact !== null && (
+ {" "}${item.dollarImpact.toLocaleString()} estimated impact.
+ )}
+
+
+
+
+
+
+
+ {item.ownerEmail.charAt(0).toUpperCase()}
+
+
+ {item.ownerEmail} · Vendor Owner
+
+
+
+
+
+
+
+ Acknowledge before EOD. Route to legal if data classification policy fires.
+ {item.severity === "P1" && " Escalate immediately — P1 severity."}
+
+
+
+ {isChange && (
+
+
+ {changes.length > 0
+ ?
+ : Loading diff data...
+ }
+
+ )}
+
+ {escalate === "confirming" && (
+
+
+ Escalate this change
+
+
+
+ {escalateError && (
+
+ {escalateError}
+
+ )}
+
+
+
+
+
+ )}
+
+ );
+}
+
+export function ChangeDrawer({ open, onClose, item, onEscalated }: ChangeDrawerProps): JSX.Element | null {
+ const panelRef = useRef(null);
+ const [evidence, setEvidence] = useState(null);
+ const [toast, setToast] = useState<{ message: string; variant: ToastVariant } | null>(null);
+ const [actionBusy, setActionBusy] = useState(null);
+ const [escalate, setEscalate] = useState("idle");
+ const [escalateRole, setEscalateRole] = useState("legal");
+ const [escalateNote, setEscalateNote] = useState("");
+ const [escalateBusy, setEscalateBusy] = useState(false);
+ const [escalateError, setEscalateError] = useState(null);
+
+ useFocusTrap(open, panelRef, onClose);
+
+ useEffect(() => {
+ if (!open || !item || item.kind !== "change") { setEvidence(null); return; }
+ let cancelled = false;
+ fetch(`/v1/evidence/${encodeURIComponent(item.id)}`, { headers: { Authorization: `Bearer ${DEMO_BEARER_TOKEN}` } })
+ .then((r) => r.ok ? r.json() : null)
+ .then((d: EvidenceBriefResponse | null) => { if (!cancelled) setEvidence(d); })
+ .catch(() => { if (!cancelled) setEvidence(null); });
+ return () => { cancelled = true; };
+ }, [open, item]);
+
+ useEffect(() => {
+ if (!open) {
+ setEscalate("idle");
+ setEscalateRole("legal");
+ setEscalateNote("");
+ setEscalateError(null);
+ setEscalateBusy(false);
+ }
+ }, [open]);
+
+ useEffect(() => {
+ if (!toast) return;
+ const t = setTimeout(() => setToast(null), 3000);
+ return () => clearTimeout(t);
+ }, [toast]);
+
+ const showToast = (msg: string, variant: ToastVariant = "success"): void => setToast({ message: msg, variant });
+
+ const handleAction = async (key: string, body?: Record, successMsg = "Done"): Promise => {
+ if (!item) return;
+ setActionBusy(key);
+ try {
+ await postChange(`/v1/changes/${item.id}/${key}`, body);
+ showToast(successMsg);
+ if (key === "resolve" && item.severity === "P1") celebrate();
+ onClose();
+ } catch {
+ showToast("Action failed", "error");
+ } finally {
+ setActionBusy(null);
+ }
+ };
+
+ const handleSubmitEscalate = async (): Promise => {
+ if (!item) return;
+ setEscalateBusy(true);
+ setEscalateError(null);
+ try {
+ const trimmed = escalateNote.trim();
+ const body: Record = { toRole: escalateRole };
+ if (trimmed.length > 0) body.note = trimmed;
+ await postChange(`/v1/changes/${item.id}/escalate`, body);
+ showToast(`Escalated to ${escalateRole} — Slack + Jira sent`);
+ onEscalated?.(item.id, escalateRole);
+ setEscalate("idle");
+ onClose();
+ } catch (err) {
+ const msg = err instanceof ApiError ? err.message : "Escalation failed";
+ setEscalateError(msg);
+ } finally {
+ setEscalateBusy(false);
+ }
+ };
+
+ if (!open && !item) return null;
+
+ const sevBadgeClass = item ? SEV_BADGE[item.severity] : "badge badge-neutral";
+ const SevIcon = item?.severity === "P3" ? CheckCircle : AlertCircle;
+
+ return createPortal(
+ <>
+
+
+ {/* Header */}
+
+ {item && (
+
+ {item.severity}
+
+ )}
+
+ {item ? `${item.vendorName} · Material Change` : "Material Change"}
+
+
+
+
+ {/* Body */}
+ {item && (
+
void handleSubmitEscalate()}
+ escalateBusy={escalateBusy}
+ escalateError={escalateError}
+ escalateRole={escalateRole}
+ escalateNote={escalateNote}
+ onChangeRole={setEscalateRole}
+ onChangeNote={setEscalateNote}
+ />
+ )}
+
+ {/* Action row */}
+ {item && (
+
+
+
+
+
+
+ )}
+
+ {toast && }
+ >,
+ document.body,
+ );
+}
diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx
new file mode 100644
index 0000000..81823f9
--- /dev/null
+++ b/apps/web/src/components/CommandPalette.tsx
@@ -0,0 +1,425 @@
+import { useEffect, useRef, useState, useCallback } from "react";
+import { createPortal } from "react-dom";
+import { useRole } from "../lib/role.js";
+import { useGlobalShortcut } from "../lib/keyboard.js";
+
+type Role = "procurement" | "legal" | "security" | "finance";
+
+interface PaletteItem {
+ id: string;
+ label: string;
+ hint?: string;
+ group: string;
+ disabled?: boolean;
+ action: () => void;
+}
+
+const SEED_VENDORS = [
+ { name: "Notion", slug: "notion" },
+ { name: "Stripe", slug: "stripe" },
+ { name: "Figma", slug: "figma" },
+ { name: "Vercel", slug: "vercel" },
+ { name: "Linear", slug: "linear" },
+ { name: "GitHub", slug: "github" },
+ { name: "Slack", slug: "slack" },
+ { name: "Salesforce", slug: "salesforce" },
+];
+
+const SEED_CHANGES = [
+ { id: "chg_seed_notion", label: "Notion — data retention change" },
+ { id: "chg_seed_stripe_subprocessor", label: "Stripe — subprocessor update" },
+];
+
+const ROLES: Role[] = ["procurement", "legal", "security", "finance"];
+
+function navigate(path: string): void {
+ window.history.pushState({}, "", path);
+ window.dispatchEvent(new PopStateEvent("popstate"));
+}
+
+function useVendorList(): { name: string; slug: string }[] {
+ const [vendors, setVendors] = useState(SEED_VENDORS);
+
+ useEffect(() => {
+ fetch("/v1/dashboard/summary")
+ .then((r) => (r.ok ? r.json() : null))
+ .then((data: unknown) => {
+ if (!data || typeof data !== "object") return;
+ const d = data as Record;
+ const list = d.vendors ?? d.data;
+ if (!Array.isArray(list)) return;
+ const mapped = list
+ .filter((v): v is { name?: unknown; id?: unknown; slug?: unknown } =>
+ typeof v === "object" && v !== null
+ )
+ .map((v) => ({
+ name: String(v.name ?? v.id ?? ""),
+ slug: String(v.slug ?? v.id ?? ""),
+ }))
+ .filter((v) => v.name && v.slug);
+ if (mapped.length > 0) setVendors(mapped);
+ })
+ .catch(() => {
+ // fall back to seed vendors already in state
+ });
+ }, []);
+
+ return vendors;
+}
+
+function substrMatch(haystack: string, needle: string): boolean {
+ return haystack.toLowerCase().includes(needle.toLowerCase());
+}
+
+interface PaletteProps {
+ open: boolean;
+ onClose: () => void;
+}
+
+function Palette({ open, onClose }: PaletteProps): JSX.Element | null {
+ const [query, setQuery] = useState("");
+ const [activeIdx, setActiveIdx] = useState(0);
+ const inputRef = useRef(null);
+ const listRef = useRef(null);
+ const dialogRef = useRef(null);
+ const vendors = useVendorList();
+ const [, setRole] = useRole();
+ const pathname =
+ typeof window !== "undefined" ? window.location.pathname : "/app";
+
+ const isChangePage = /^\/app\/(change|evidence)\/([^/?#]+)/.test(pathname);
+ const changeIdMatch = pathname.match(
+ /^\/app\/(change|evidence)\/([^/?#]+)/
+ );
+ const currentChangeId = changeIdMatch?.[2];
+
+ const buildItems = useCallback((): PaletteItem[] => {
+ const items: PaletteItem[] = [];
+
+ vendors.forEach((v) => {
+ items.push({
+ id: `vendor-${v.slug}`,
+ label: v.name,
+ group: "Jump to vendor",
+ action: () => navigate(`/app/vendor/${v.slug}`),
+ });
+ });
+
+ SEED_CHANGES.forEach((c) => {
+ items.push({
+ id: `change-${c.id}`,
+ label: c.label,
+ group: "Open recent change",
+ action: () => navigate(`/app/change/${c.id}`),
+ });
+ });
+
+ ROLES.forEach((r) => {
+ const label = r.charAt(0).toUpperCase() + r.slice(1);
+ items.push({
+ id: `role-${r}`,
+ label: `Switch to ${label}`,
+ group: "Switch role",
+ action: () => {
+ setRole(r);
+ onClose();
+ },
+ });
+ });
+
+ SEED_CHANGES.forEach((c) => {
+ items.push({
+ id: `brief-${c.id}`,
+ label: `Senso brief — ${c.label}`,
+ group: "Open Senso brief",
+ action: () => navigate(`/app/evidence/${c.id}`),
+ });
+ });
+
+ if (isChangePage && currentChangeId) {
+ items.push({
+ id: "bundle",
+ label: "Generate Compliance Bundle",
+ hint: "Opens printable HTML",
+ group: "Actions",
+ action: () =>
+ window.open(`/v1/evidence/${currentChangeId}/bundle.html`, "_blank"),
+ });
+ } else {
+ items.push({
+ id: "bundle",
+ label: "Generate Compliance Bundle",
+ hint: "Open a change first",
+ group: "Actions",
+ disabled: true,
+ action: () => {},
+ });
+ }
+
+ return items;
+ }, [vendors, isChangePage, currentChangeId, setRole, onClose]);
+
+ const filtered = buildItems().filter(
+ (item) => !query || substrMatch(item.label, query)
+ );
+
+ useEffect(() => {
+ if (open) {
+ setQuery("");
+ setActiveIdx(0);
+ requestAnimationFrame(() => inputRef.current?.focus());
+ }
+ }, [open]);
+
+ useEffect(() => {
+ setActiveIdx(0);
+ }, [query]);
+
+ useEffect(() => {
+ const el = listRef.current?.querySelector(
+ `[data-idx="${activeIdx}"]`
+ );
+ el?.scrollIntoView({ block: "nearest" });
+ }, [activeIdx]);
+
+ const FOCUSABLE = 'button:not([disabled]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
+
+ const handleKeyDown = (e: React.KeyboardEvent): void => {
+ if (e.key === "Tab") {
+ const dialog = dialogRef.current;
+ if (!dialog) return;
+ const focusable = Array.from(dialog.querySelectorAll(FOCUSABLE)).filter(
+ (el) => !el.hasAttribute("disabled"),
+ );
+ if (focusable.length === 0) return;
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ if (e.shiftKey) {
+ if (document.activeElement === first) {
+ e.preventDefault();
+ last?.focus();
+ }
+ } else {
+ if (document.activeElement === last) {
+ e.preventDefault();
+ first?.focus();
+ }
+ }
+ } else if (e.key === "ArrowDown") {
+ e.preventDefault();
+ setActiveIdx((i) => Math.min(i + 1, filtered.length - 1));
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ setActiveIdx((i) => Math.max(i - 1, 0));
+ } else if (e.key === "Enter") {
+ e.preventDefault();
+ const item = filtered[activeIdx];
+ if (item && !item.disabled) {
+ item.action();
+ onClose();
+ }
+ } else if (e.key === "Escape") {
+ e.stopPropagation();
+ onClose();
+ }
+ };
+
+ if (!open) return null;
+
+ let lastGroup = "";
+
+ return createPortal(
+ {
+ if (e.target === e.currentTarget) onClose();
+ }}
+ >
+
+
+ setQuery(e.currentTarget.value)}
+ style={{ width: "100%", border: "none", background: "transparent" }}
+ aria-label="Search commands"
+ autoComplete="off"
+ />
+
+
+
+ {filtered.length === 0 && (
+ -
+ No results
+
+ )}
+ {filtered.map((item, idx) => {
+ const showGroup = item.group !== lastGroup;
+ lastGroup = item.group;
+ return (
+ -
+ {showGroup && (
+
+ {item.group}
+
+ )}
+
+
+ );
+ })}
+
+
+
,
+ document.body
+ );
+}
+
+export function CommandPalette(): JSX.Element {
+ const [open, setOpen] = useState(false);
+ const triggerRef = useRef(null);
+
+ const openPalette = useCallback(() => {
+ triggerRef.current = document.activeElement as HTMLElement;
+ setOpen(true);
+ }, []);
+
+ const closePalette = useCallback(() => {
+ setOpen(false);
+ requestAnimationFrame(() => {
+ if (triggerRef.current && triggerRef.current !== document.body) {
+ triggerRef.current.focus();
+ }
+ });
+ }, []);
+
+ useGlobalShortcut("Meta+K", openPalette);
+ useGlobalShortcut("Control+K", openPalette);
+
+ useEffect(() => {
+ const onOpen = (): void => openPalette();
+ window.addEventListener("unsyphn:openPalette", onOpen);
+ return () => window.removeEventListener("unsyphn:openPalette", onOpen);
+ }, [openPalette]);
+
+ useEffect(() => {
+ if (!open) return;
+ const onEsc = (e: KeyboardEvent): void => {
+ if (e.key === "Escape") closePalette();
+ };
+ window.addEventListener("keydown", onEsc);
+ return () => window.removeEventListener("keydown", onEsc);
+ }, [open, closePalette]);
+
+ return ;
+}
diff --git a/apps/web/src/components/ConnectDrawer.tsx b/apps/web/src/components/ConnectDrawer.tsx
new file mode 100644
index 0000000..fe4c49a
--- /dev/null
+++ b/apps/web/src/components/ConnectDrawer.tsx
@@ -0,0 +1,537 @@
+import { useEffect, useMemo, useState } from "react";
+import { Copy, Check, Upload, Loader2, ExternalLink } from "lucide-react";
+import { Drawer } from "./Drawer.js";
+import { VendorLogo } from "./VendorLogo.js";
+import { DEMO_BEARER_TOKEN } from "../lib/api.js";
+
+export interface IntegrationField {
+ key: string;
+ label: string;
+ placeholder?: string;
+ sensitive?: boolean;
+ optional?: boolean;
+}
+
+export interface IntegrationDto {
+ id: string;
+ name: string;
+ slug: string;
+ category: "inbound" | "outbound";
+ description: string;
+ authType: "oauth" | "api-key" | "saml";
+ iconSlug?: string;
+ iconColor?: string;
+ requiredFields: IntegrationField[];
+ defaultScopes: string[];
+ connected: boolean;
+ scopes?: string[];
+ connectedAs?: string;
+ connectedAt?: string;
+ lastSyncedAt?: string;
+}
+
+interface Props {
+ integration: IntegrationDto;
+ open: boolean;
+ onClose: () => void;
+ onConnected: (next: IntegrationDto) => void;
+}
+
+const S = {
+ header: {
+ display: "flex",
+ alignItems: "flex-start",
+ gap: "var(--space-3)",
+ marginBottom: "var(--space-4)",
+ } as React.CSSProperties,
+ headerText: { minWidth: 0, flex: 1 } as React.CSSProperties,
+ title: {
+ fontSize: "var(--text-base)",
+ fontWeight: 600,
+ color: "var(--text)",
+ margin: 0,
+ } as React.CSSProperties,
+ categoryChip: {
+ display: "inline-block",
+ marginTop: 4,
+ fontSize: "var(--text-xs)",
+ color: "var(--text-2)",
+ background: "var(--surface-2)",
+ borderRadius: "var(--radius-pill)",
+ padding: "2px 8px",
+ textTransform: "uppercase" as const,
+ letterSpacing: "0.06em",
+ },
+ description: {
+ fontSize: "var(--text-sm)",
+ color: "var(--text-2)",
+ lineHeight: 1.55,
+ marginBottom: "var(--space-5)",
+ } as React.CSSProperties,
+ fieldGroup: { marginBottom: "var(--space-4)" } as React.CSSProperties,
+ label: {
+ display: "flex",
+ alignItems: "center",
+ gap: 6,
+ fontSize: "var(--text-xs)",
+ fontWeight: 500,
+ color: "var(--text-2)",
+ marginBottom: "var(--space-2)",
+ } as React.CSSProperties,
+ optional: {
+ fontSize: "var(--text-xs)",
+ color: "var(--text-muted)",
+ fontWeight: 400,
+ } as React.CSSProperties,
+ input: {
+ width: "100%",
+ padding: "8px var(--space-3)",
+ fontSize: "var(--text-sm)",
+ border: "1px solid var(--border-strong)",
+ borderRadius: "var(--radius-sm)",
+ background: "var(--surface)",
+ color: "var(--text)",
+ boxSizing: "border-box" as const,
+ },
+ fileBtn: {
+ display: "inline-flex",
+ alignItems: "center",
+ gap: 6,
+ fontSize: "var(--text-xs)",
+ fontWeight: 500,
+ padding: "8px 12px",
+ border: "1px dashed var(--border-strong)",
+ borderRadius: "var(--radius-sm)",
+ background: "var(--surface)",
+ color: "var(--text-2)",
+ cursor: "pointer",
+ },
+ oauthBtn: {
+ width: "100%",
+ padding: "10px var(--space-4)",
+ fontSize: "var(--text-sm)",
+ fontWeight: 500,
+ border: "1px solid var(--border-strong)",
+ borderRadius: "var(--radius-md)",
+ background: "var(--surface)",
+ color: "var(--text)",
+ cursor: "pointer",
+ display: "inline-flex",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 8,
+ } as React.CSSProperties,
+ oauthSuccess: {
+ fontSize: "var(--text-xs)",
+ color: "var(--success)",
+ marginTop: "var(--space-2)",
+ display: "inline-flex",
+ alignItems: "center",
+ gap: 6,
+ } as React.CSSProperties,
+ scopesBlock: {
+ background: "var(--surface-2)",
+ border: "1px solid var(--border)",
+ borderRadius: "var(--radius-md)",
+ padding: "var(--space-3) var(--space-4)",
+ marginBottom: "var(--space-4)",
+ } as React.CSSProperties,
+ scopeLabel: {
+ display: "flex",
+ alignItems: "center",
+ gap: 8,
+ padding: "4px 0",
+ fontSize: "var(--text-sm)",
+ color: "var(--text)",
+ cursor: "pointer",
+ } as React.CSSProperties,
+ webhookRow: {
+ display: "flex",
+ gap: "var(--space-2)",
+ alignItems: "center",
+ } as React.CSSProperties,
+ webhookInput: {
+ flex: 1,
+ fontFamily: "var(--font-mono)",
+ fontSize: "11px",
+ padding: "8px var(--space-3)",
+ border: "1px solid var(--border-strong)",
+ borderRadius: "var(--radius-sm)",
+ background: "var(--surface-2)",
+ color: "var(--text-2)",
+ boxSizing: "border-box" as const,
+ },
+ copyBtn: {
+ display: "inline-flex",
+ alignItems: "center",
+ gap: 4,
+ padding: "8px 12px",
+ fontSize: "var(--text-xs)",
+ border: "1px solid var(--border-strong)",
+ borderRadius: "var(--radius-sm)",
+ background: "var(--surface)",
+ color: "var(--text-2)",
+ cursor: "pointer",
+ } as React.CSSProperties,
+ sectionHeading: {
+ fontSize: "var(--text-xs)",
+ fontWeight: 500,
+ color: "var(--text-muted)",
+ textTransform: "uppercase" as const,
+ letterSpacing: "0.07em",
+ marginBottom: "var(--space-2)",
+ } as React.CSSProperties,
+ footer: {
+ display: "flex",
+ gap: "var(--space-3)",
+ marginTop: "var(--space-5)",
+ } as React.CSSProperties,
+ submit: {
+ flex: 1,
+ padding: "10px var(--space-4)",
+ fontSize: "var(--text-sm)",
+ fontWeight: 500,
+ background: "var(--accent)",
+ color: "#fff",
+ border: "1px solid var(--accent)",
+ borderRadius: "var(--radius-md)",
+ cursor: "pointer",
+ display: "inline-flex",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 6,
+ } as React.CSSProperties,
+ cancel: {
+ padding: "10px var(--space-4)",
+ fontSize: "var(--text-sm)",
+ fontWeight: 500,
+ background: "var(--surface)",
+ color: "var(--text-2)",
+ border: "1px solid var(--border-strong)",
+ borderRadius: "var(--radius-md)",
+ cursor: "pointer",
+ } as React.CSSProperties,
+ error: {
+ fontSize: "var(--text-xs)",
+ color: "var(--danger)",
+ marginTop: "var(--space-3)",
+ } as React.CSSProperties,
+} as const;
+
+function shortToken(): string {
+ return Math.random().toString(36).slice(2, 10);
+}
+
+function domainFor(slug: string): string {
+ const map: Record = {
+ "google-workspace": "google.com",
+ "microsoft-365": "microsoft.com",
+ "okta-saml": "okta.com",
+ "azure-ad": "microsoft.com",
+ "microsoft-teams": "microsoft.com",
+ "aws-cost-explorer": "aws.amazon.com",
+ "gcp-billing": "cloud.google.com",
+ };
+ return map[slug] ?? `${slug.replace(/-/g, "")}.com`;
+}
+
+function fakeAuthorizedEmail(slug: string): string {
+ const domain = domainFor(slug);
+ const handles = ["priya.shah", "marcus.chen", "lin.park", "ada.owens"];
+ const handle = handles[Math.floor(Math.random() * handles.length)] ?? "priya.shah";
+ return `${handle}@${domain}`;
+}
+
+export function ConnectDrawer({ integration, open, onClose, onConnected }: Props): JSX.Element {
+ const [values, setValues] = useState>({});
+ const [scopes, setScopes] = useState(integration.defaultScopes);
+ const [oauthState, setOauthState] = useState<"idle" | "authorizing" | "authorized">("idle");
+ const [authorizedEmail, setAuthorizedEmail] = useState(null);
+ const [fileName, setFileName] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+ const [copied, setCopied] = useState(false);
+
+ // Reset state on open so the drawer is clean each time.
+ useEffect(() => {
+ if (!open) return;
+ setValues({});
+ setScopes(integration.defaultScopes);
+ setOauthState("idle");
+ setAuthorizedEmail(null);
+ setFileName(null);
+ setSubmitting(false);
+ setError(null);
+ setCopied(false);
+ }, [open, integration.id, integration.defaultScopes]);
+
+ const webhookUrl = useMemo(
+ () => `https://api.unsyphn.com/v1/webhooks/inbound/${integration.id}/${shortToken()}`,
+ // Regenerate each open cycle; integration.id makes it differ per integration.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [open, integration.id],
+ );
+
+ const needsWebhook = integration.requiredFields.some((f) => f.key === "webhookUrl");
+
+ function setField(key: string, val: string): void {
+ setValues((prev) => ({ ...prev, [key]: val }));
+ }
+
+ function toggleScope(scope: string): void {
+ setScopes((prev) =>
+ prev.includes(scope) ? prev.filter((s) => s !== scope) : [...prev, scope],
+ );
+ }
+
+ function simulateOauth(): void {
+ setOauthState("authorizing");
+ window.setTimeout(() => {
+ setOauthState("authorized");
+ setAuthorizedEmail(fakeAuthorizedEmail(integration.slug));
+ }, 1200);
+ }
+
+ function copyWebhook(): void {
+ void navigator.clipboard.writeText(webhookUrl).then(() => {
+ setCopied(true);
+ window.setTimeout(() => setCopied(false), 1800);
+ });
+ }
+
+ function canSubmit(): boolean {
+ if (integration.authType === "oauth") return oauthState === "authorized";
+ const required = integration.requiredFields.filter((f) => !f.optional && f.key !== "webhookUrl");
+ return required.every((f) => (values[f.key] ?? "").trim().length > 0);
+ }
+
+ async function submit(e: React.FormEvent): Promise {
+ e.preventDefault();
+ if (!canSubmit() || submitting) return;
+ setSubmitting(true);
+ setError(null);
+ const submitValues: Record = { ...values };
+ if (needsWebhook && !submitValues.webhookUrl) submitValues.webhookUrl = webhookUrl;
+ if (fileName) submitValues.certificateFile = fileName;
+ try {
+ const resp = await fetch(`/v1/integrations/${encodeURIComponent(integration.id)}/connect`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${DEMO_BEARER_TOKEN}`,
+ },
+ body: JSON.stringify({
+ values: submitValues,
+ scopes: integration.authType === "oauth" ? scopes : integration.defaultScopes,
+ ...(authorizedEmail ? { connectedAs: authorizedEmail } : {}),
+ }),
+ });
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
+ const json = (await resp.json()) as { integration: IntegrationDto };
+ onConnected(json.integration);
+ onClose();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Connection failed");
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ return (
+
+
+
+
+
{integration.name}
+ {integration.category}
+
+
+ {integration.description}
+
+
+
+ );
+}
diff --git a/apps/web/src/components/CountUp.tsx b/apps/web/src/components/CountUp.tsx
new file mode 100644
index 0000000..eed16eb
--- /dev/null
+++ b/apps/web/src/components/CountUp.tsx
@@ -0,0 +1,63 @@
+import { useEffect, useRef, useState, type CSSProperties } from "react";
+
+interface CountUpProps {
+ value: number;
+ durationMs?: number;
+ format?: (n: number) => string;
+ className?: string;
+ style?: CSSProperties;
+}
+
+const reduced = (): boolean =>
+ typeof window !== "undefined" &&
+ typeof window.matchMedia === "function" &&
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+
+export function CountUp({
+ value,
+ durationMs = 900,
+ format,
+ className,
+ style,
+}: CountUpProps): JSX.Element {
+ const [display, setDisplay] = useState(reduced() ? value : 0);
+ const fromRef = useRef(0);
+ const startRef = useRef(null);
+ const rafRef = useRef(null);
+ const displayRef = useRef(display);
+ displayRef.current = display;
+
+ useEffect(() => {
+ if (reduced()) {
+ setDisplay(value);
+ return;
+ }
+ fromRef.current = displayRef.current;
+ startRef.current = null;
+ const tick = (now: number): void => {
+ if (startRef.current === null) startRef.current = now;
+ const elapsed = now - startRef.current;
+ const p = Math.min(1, elapsed / durationMs);
+ const eased = 1 - Math.pow(1 - p, 3);
+ const next = fromRef.current + (value - fromRef.current) * eased;
+ setDisplay(next);
+ if (p < 1) rafRef.current = requestAnimationFrame(tick);
+ };
+ rafRef.current = requestAnimationFrame(tick);
+ return () => {
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
+ };
+ }, [value, durationMs]);
+
+ const formatted = format
+ ? format(display)
+ : Number.isInteger(value)
+ ? Math.round(display).toString()
+ : display.toFixed(1);
+
+ return (
+
+ {formatted}
+
+ );
+}
diff --git a/apps/web/src/components/DiffViewer.tsx b/apps/web/src/components/DiffViewer.tsx
new file mode 100644
index 0000000..f918e26
--- /dev/null
+++ b/apps/web/src/components/DiffViewer.tsx
@@ -0,0 +1,197 @@
+import type { Change } from "@unsyphn/shared";
+
+interface Props {
+ changes: Change[];
+}
+
+const CATEGORY_LABEL: Record = {
+ data: "Data",
+ pricing: "Pricing",
+ subprocessor: "Sub-processor",
+ terms: "Terms",
+ sla: "SLA",
+ security: "Security",
+};
+
+const MATERIALITY_CLASS: Record = {
+ material: "badge badge-danger",
+ minor: "badge badge-warning",
+ cosmetic: "badge badge-neutral",
+};
+
+function ChangeCard({ change }: { change: Change }): JSX.Element {
+ const categoryLabel = change.category ? (CATEGORY_LABEL[change.category] ?? change.category) : null;
+ const materialityClass = change.materiality
+ ? (MATERIALITY_CLASS[change.materiality] ?? "badge badge-neutral")
+ : "badge badge-neutral";
+
+ return (
+
+ {/* Category + materiality row */}
+
+ {categoryLabel && (
+
+ {categoryLabel}
+
+ )}
+ {change.materiality && (
+
+ {change.materiality} materiality
+
+ )}
+
+
+ {/* Summary */}
+
+ {change.summary}
+
+
+ {/* Before / After */}
+ {(change.before !== undefined || change.after !== undefined) && (
+
+ {change.before !== undefined && (
+
+
+ Before
+
+
+ {change.before}
+
+
+ )}
+ {change.after !== undefined && (
+
+
+ After
+
+
+ {change.after}
+
+
+ )}
+
+ )}
+
+ {/* Citations */}
+ {change.citations && change.citations.length > 0 && (
+
+ {change.citations.map((cite, i) => {
+ const url = cite.url ?? cite.sourceUrl;
+ const date = cite.fetchedAt
+ ? new Date(cite.fetchedAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" })
+ : null;
+ return (
+
+ Citation:{" "}
+ {url ? (
+
+ {url.replace(/^https?:\/\//, "").split("/")[0]}
+
+ ) : (
+ cite.section ?? cite.label ?? "Source"
+ )}
+ {date && ` · ${date}`}
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
+export function DiffViewer({ changes }: Props): JSX.Element {
+ if (changes.length === 0) {
+ return (
+
+ No diff data available.
+
+ );
+ }
+
+ return (
+
+ {changes.map((change, i) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/web/src/components/Drawer.tsx b/apps/web/src/components/Drawer.tsx
new file mode 100644
index 0000000..6766775
--- /dev/null
+++ b/apps/web/src/components/Drawer.tsx
@@ -0,0 +1,164 @@
+import { useEffect, useRef, type ReactNode } from "react";
+import { X } from "lucide-react";
+
+interface Props {
+ open: boolean;
+ onClose: () => void;
+ children: ReactNode;
+ title?: string;
+}
+
+const FOCUSABLE = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
+
+export function Drawer({ open, onClose, children, title }: Props): JSX.Element {
+ const panelRef = useRef(null);
+ const openerRef = useRef(null);
+ const titleId = "drawer-title";
+
+ useEffect(() => {
+ if (open) {
+ openerRef.current = document.activeElement;
+ const first = panelRef.current?.querySelectorAll(FOCUSABLE)[0];
+ first?.focus();
+ } else {
+ const opener = openerRef.current;
+ if (opener instanceof HTMLElement) opener.focus();
+ openerRef.current = null;
+ }
+ }, [open]);
+
+ useEffect(() => {
+ if (!open) return;
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ e.preventDefault();
+ onClose();
+ return;
+ }
+
+ if (e.key !== "Tab") return;
+
+ const panel = panelRef.current;
+ if (!panel) return;
+
+ const focusable = Array.from(panel.querySelectorAll(FOCUSABLE)).filter(
+ (el) => !el.hasAttribute("disabled"),
+ );
+ if (focusable.length === 0) return;
+
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+
+ if (first === undefined || last === undefined) return;
+
+ if (e.shiftKey) {
+ if (document.activeElement === first) {
+ e.preventDefault();
+ last.focus();
+ }
+ } else {
+ if (document.activeElement === last) {
+ e.preventDefault();
+ first.focus();
+ }
+ }
+ };
+
+ document.addEventListener("keydown", handleKeyDown);
+ return () => document.removeEventListener("keydown", handleKeyDown);
+ }, [open, onClose]);
+
+ return (
+ <>
+
+
+
+ {title && (
+
+ {title}
+
+ )}
+
+
+
+ {children}
+
+
+ >
+ );
+}
diff --git a/apps/web/src/components/FleetStats.tsx b/apps/web/src/components/FleetStats.tsx
new file mode 100644
index 0000000..82a72ad
--- /dev/null
+++ b/apps/web/src/components/FleetStats.tsx
@@ -0,0 +1,112 @@
+import { useEffect, useState } from "react";
+import { DEMO_BEARER_TOKEN } from "../lib/api.js";
+
+interface DashboardSummary {
+ vendorCount: number;
+ annualRunRateUsd: number;
+ openChangeCount: number;
+}
+
+interface StatItem {
+ value: string;
+ label: string;
+}
+
+const CHANGES_THIS_WEEK = 12;
+
+function formatArr(usd: number): string {
+ if (usd >= 1_000_000) return `$${(usd / 1_000_000).toFixed(1)}M`;
+ if (usd >= 1_000) return `$${(usd / 1_000).toFixed(0)}k`;
+ return `$${usd}`;
+}
+
+export function FleetStats(): JSX.Element {
+ const [summary, setSummary] = useState(null);
+ const [fetchFailed, setFetchFailed] = useState(false);
+
+ useEffect(() => {
+ fetch("/v1/dashboard/summary", {
+ headers: { Authorization: `Bearer ${DEMO_BEARER_TOKEN}` },
+ })
+ .then((r) => {
+ if (!r.ok) throw new Error(`${r.status}`);
+ return r.json() as Promise;
+ })
+ .then(setSummary)
+ .catch((err: unknown) => {
+ setFetchFailed(true);
+ if (err instanceof Error) console.error("FleetStats fetch failed:", err.message);
+ });
+ }, []);
+
+ const stats: StatItem[] = summary
+ ? [
+ { value: String(summary.vendorCount), label: "Vendors" },
+ { value: String(CHANGES_THIS_WEEK), label: "Changes this week" },
+ { value: String(summary.openChangeCount), label: "Open P1" },
+ { value: formatArr(summary.annualRunRateUsd), label: "ARR monitored" },
+ ]
+ : [
+ { value: "—", label: "Vendors" },
+ { value: "—", label: "Changes this week" },
+ { value: "—", label: "Open P1" },
+ { value: "—", label: "ARR monitored" },
+ ];
+
+ return (
+
+ {fetchFailed && (
+
+ Stats unavailable
+
+ )}
+ {stats.map((stat, i) => (
+
+ {i > 0 && (
+
+ ·
+
+ )}
+
+ {stat.value}
+
+
+ {stat.label}
+
+
+ ))}
+
+ );
+}
diff --git a/apps/web/src/components/IntegrationCard.tsx b/apps/web/src/components/IntegrationCard.tsx
new file mode 100644
index 0000000..3c564dd
--- /dev/null
+++ b/apps/web/src/components/IntegrationCard.tsx
@@ -0,0 +1,209 @@
+import { useEffect, useState } from "react";
+import { CheckCircle, RefreshCcw, Settings as SettingsIcon } from "lucide-react";
+import { VendorLogo } from "./VendorLogo.js";
+import type { IntegrationDto } from "./ConnectDrawer.js";
+
+interface Props {
+ integration: IntegrationDto;
+ onConnect: (i: IntegrationDto) => void;
+ onManage: (i: IntegrationDto) => void;
+ onSyncNow: (i: IntegrationDto) => void;
+ syncBusy: boolean;
+}
+
+const S = {
+ card: {
+ borderRadius: "var(--radius-md)",
+ padding: "var(--space-4)",
+ display: "flex",
+ flexDirection: "column" as const,
+ gap: "var(--space-3)",
+ minHeight: 168,
+ } as React.CSSProperties,
+ header: { display: "flex", alignItems: "flex-start", gap: "var(--space-3)" } as React.CSSProperties,
+ name: {
+ fontSize: "var(--text-sm)",
+ fontWeight: 600,
+ color: "var(--text)",
+ margin: 0,
+ lineHeight: 1.3,
+ } as React.CSSProperties,
+ chip: {
+ display: "inline-block",
+ fontSize: 10,
+ fontWeight: 500,
+ color: "var(--text-2)",
+ background: "var(--surface-2)",
+ borderRadius: "var(--radius-pill)",
+ padding: "1px 7px",
+ marginTop: 4,
+ textTransform: "uppercase" as const,
+ letterSpacing: "0.06em",
+ } as React.CSSProperties,
+ description: {
+ fontSize: "var(--text-xs)",
+ color: "var(--text-2)",
+ lineHeight: 1.5,
+ margin: 0,
+ display: "-webkit-box",
+ WebkitLineClamp: 2,
+ WebkitBoxOrient: "vertical" as const,
+ overflow: "hidden",
+ minHeight: 36,
+ } as React.CSSProperties,
+ status: {
+ display: "flex",
+ alignItems: "center",
+ gap: 6,
+ fontSize: "var(--text-xs)",
+ fontWeight: 500,
+ color: "var(--success)",
+ } as React.CSSProperties,
+ pulseDot: {
+ width: 7,
+ height: 7,
+ borderRadius: "50%",
+ background: "var(--success)",
+ boxShadow: "0 0 0 0 rgba(63,207,142,0.6)",
+ animation: "pulse-dot 2s infinite",
+ } as React.CSSProperties,
+ notConnected: {
+ fontSize: "var(--text-xs)",
+ fontWeight: 400,
+ color: "var(--text-muted)",
+ } as React.CSSProperties,
+ lastSync: {
+ fontSize: "var(--text-xs)",
+ color: "var(--text-muted)",
+ } as React.CSSProperties,
+ actions: {
+ display: "flex",
+ gap: "var(--space-2)",
+ marginTop: "auto",
+ paddingTop: "var(--space-2)",
+ } as React.CSSProperties,
+ btnPrimary: {
+ flex: 1,
+ fontSize: "var(--text-xs)",
+ fontWeight: 500,
+ padding: "6px 12px",
+ border: "1px solid var(--accent)",
+ borderRadius: "var(--radius-sm)",
+ background: "var(--accent)",
+ color: "#fff",
+ cursor: "pointer",
+ } as React.CSSProperties,
+ btnOutline: {
+ flex: 1,
+ fontSize: "var(--text-xs)",
+ fontWeight: 500,
+ padding: "6px 12px",
+ border: "1px solid var(--border-strong)",
+ borderRadius: "var(--radius-sm)",
+ background: "var(--surface)",
+ color: "var(--text-2)",
+ cursor: "pointer",
+ display: "inline-flex",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 4,
+ } as React.CSSProperties,
+} as const;
+
+function timeAgo(iso?: string, now: number = Date.now()): string {
+ if (!iso) return "never";
+ const diff = now - Date.parse(iso);
+ if (Number.isNaN(diff) || diff < 0) return "just now";
+ const sec = Math.floor(diff / 1000);
+ if (sec < 60) return `${sec}s ago`;
+ const min = Math.floor(sec / 60);
+ if (min < 60) return `${min} min${min === 1 ? "" : "s"} ago`;
+ const hr = Math.floor(min / 60);
+ if (hr < 24) return `${hr}h ago`;
+ const days = Math.floor(hr / 24);
+ return `${days}d ago`;
+}
+
+function domainFor(slug: string): string {
+ const map: Record = {
+ "google-workspace": "google.com",
+ "microsoft-365": "microsoft.com",
+ "okta-saml": "okta.com",
+ "azure-ad": "microsoft.com",
+ "microsoft-teams": "microsoft.com",
+ "aws-cost-explorer": "aws.amazon.com",
+ "gcp-billing": "cloud.google.com",
+ };
+ return map[slug] ?? `${slug.replace(/-/g, "")}.com`;
+}
+
+export function IntegrationCard({ integration, onConnect, onManage, onSyncNow, syncBusy }: Props): JSX.Element {
+ const [now, setNow] = useState(Date.now());
+
+ useEffect(() => {
+ if (!integration.connected) return;
+ const id = window.setInterval(() => setNow(Date.now()), 30_000);
+ return () => window.clearInterval(id);
+ }, [integration.connected]);
+
+ return (
+
+
+
+
+
{integration.name}
+ {integration.category}
+
+
+
{integration.description}
+
+ {integration.connected ? (
+ <>
+
+
+ Connected
+
+
+ Last synced {timeAgo(integration.lastSyncedAt ?? integration.connectedAt, now)}
+
+
+
+
+
+ >
+ ) : (
+ <>
+
+
+ Not connected
+
+
{integration.authType.toUpperCase()} auth
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/LensChips.tsx b/apps/web/src/components/LensChips.tsx
new file mode 100644
index 0000000..df13991
--- /dev/null
+++ b/apps/web/src/components/LensChips.tsx
@@ -0,0 +1,99 @@
+import { useRef } from "react";
+import { ROLES, ROLE_LABELS, useRole } from "../lib/role.js";
+import type { Role } from "../lib/role.js";
+
+const CHIP_STYLES = `
+.lens-chips-bar {
+ display: flex;
+ gap: 4px;
+ align-items: center;
+ margin: 16px 0 24px;
+ flex-wrap: wrap;
+}
+.lens-chip {
+ display: inline-flex;
+ align-items: center;
+ padding: 7px 14px;
+ border-radius: 9999px;
+ border: none;
+ cursor: pointer;
+ font-family: var(--font-text);
+ font-size: 13.5px;
+ font-weight: 500;
+ background: transparent;
+ color: #475569;
+ transition:
+ background var(--dur-sm) var(--ease-out),
+ color var(--dur-sm) var(--ease-out),
+ transform var(--dur-sm) var(--ease-spring),
+ box-shadow var(--dur-sm) var(--ease-out);
+ white-space: nowrap;
+ line-height: 1;
+}
+.lens-chip:hover {
+ background: #f1f5f9;
+ transform: translateY(-1px);
+}
+.lens-chip:focus-visible {
+ outline: none;
+ box-shadow: var(--ring-focus);
+}
+.lens-chip-active {
+ background: #5E6AD2;
+ color: #ffffff;
+ box-shadow: 0 0 0 4px rgba(94,106,210,0.15), 0 2px 8px rgba(94,106,210,0.28);
+}
+.lens-chip-active:hover {
+ background: #5E6AD2;
+ transform: translateY(-1px);
+}
+@media (max-width: 640px) {
+ .lens-chips-bar {
+ margin: 12px 0 20px;
+ }
+}
+`;
+
+export function LensChips(): JSX.Element {
+ const [role, setRole] = useRole();
+ const containerRef = useRef(null);
+
+ const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
+ if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
+ e.preventDefault();
+ const dir = e.key === "ArrowRight" ? 1 : -1;
+ const next = (index + dir + ROLES.length) % ROLES.length;
+ const nextRole = ROLES[next] as Role;
+ setRole(nextRole);
+ const buttons = containerRef.current?.querySelectorAll("[role='tab']");
+ buttons?.[next]?.focus();
+ }
+ };
+
+ return (
+ <>
+
+
+ {ROLES.map((r, index) => (
+
+ ))}
+
+ >
+ );
+}
diff --git a/apps/web/src/components/ManageDrawer.tsx b/apps/web/src/components/ManageDrawer.tsx
new file mode 100644
index 0000000..7c555c1
--- /dev/null
+++ b/apps/web/src/components/ManageDrawer.tsx
@@ -0,0 +1,204 @@
+import { useEffect, useState } from "react";
+import { Trash2, AlertTriangle } from "lucide-react";
+import { Drawer } from "./Drawer.js";
+import { VendorLogo } from "./VendorLogo.js";
+import { DEMO_BEARER_TOKEN } from "../lib/api.js";
+import type { IntegrationDto } from "./ConnectDrawer.js";
+
+interface SyncRecord {
+ id: string;
+ startedAt: string;
+ durationMs: number;
+ recordsScanned: number;
+ status: "success" | "partial";
+}
+
+interface Props {
+ integration: IntegrationDto;
+ open: boolean;
+ onClose: () => void;
+ onDisconnected: (next: IntegrationDto) => void;
+}
+
+const S = {
+ header: { display: "flex", alignItems: "center", gap: "var(--space-3)", marginBottom: "var(--space-4)" } as React.CSSProperties,
+ name: { fontSize: "var(--text-base)", fontWeight: 600, color: "var(--text)", margin: 0 } as React.CSSProperties,
+ meta: { fontSize: "var(--text-xs)", color: "var(--text-muted)" } as React.CSSProperties,
+ sectionLabel: { fontSize: "var(--text-xs)", fontWeight: 500, color: "var(--text-muted)", textTransform: "uppercase" as const, letterSpacing: "0.07em", marginBottom: "var(--space-2)" } as React.CSSProperties,
+ section: { marginBottom: "var(--space-5)" } as React.CSSProperties,
+ scopeRow: { display: "flex", alignItems: "center", justifyContent: "space-between", padding: "8px var(--space-3)", borderBottom: "1px solid var(--border)", fontSize: "var(--text-sm)" } as React.CSSProperties,
+ scopeCode: { fontFamily: "var(--font-mono)", fontSize: 12, color: "var(--text)" } as React.CSSProperties,
+ revokeBtn: { fontSize: 11, padding: "3px 8px", border: "1px solid var(--border-strong)", borderRadius: "var(--radius-sm)", background: "var(--surface)", color: "var(--danger)", cursor: "pointer" } as React.CSSProperties,
+ syncRow: { display: "flex", justifyContent: "space-between", gap: "var(--space-3)", padding: "6px 0", fontSize: "var(--text-xs)", color: "var(--text-2)", borderBottom: "1px solid var(--border)" } as React.CSSProperties,
+ syncStatusOk: { color: "var(--success)" } as React.CSSProperties,
+ syncStatusPartial: { color: "#A87900" } as React.CSSProperties,
+ block: { border: "1px solid var(--border)", borderRadius: "var(--radius-md)", padding: "var(--space-3) var(--space-4)", background: "var(--surface-2)" } as React.CSSProperties,
+ disconnectBtn: { width: "100%", padding: "10px var(--space-4)", fontSize: "var(--text-sm)", fontWeight: 500, background: "var(--surface)", color: "var(--danger)", border: "1px solid var(--danger)", borderRadius: "var(--radius-md)", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 6 } as React.CSSProperties,
+ confirm: { background: "rgba(244,113,116,0.08)", border: "1px solid rgba(244,113,116,0.25)", borderRadius: "var(--radius-md)", padding: "var(--space-3) var(--space-4)", marginTop: "var(--space-3)", fontSize: "var(--text-sm)", color: "var(--text)" } as React.CSSProperties,
+ confirmRow: { display: "flex", gap: "var(--space-2)", marginTop: "var(--space-3)" } as React.CSSProperties,
+ confirmYes: { flex: 1, padding: "8px var(--space-3)", fontSize: "var(--text-sm)", background: "var(--danger)", color: "#fff", border: "1px solid var(--danger)", borderRadius: "var(--radius-sm)", cursor: "pointer" } as React.CSSProperties,
+ confirmNo: { padding: "8px var(--space-3)", fontSize: "var(--text-sm)", background: "var(--surface)", color: "var(--text-2)", border: "1px solid var(--border-strong)", borderRadius: "var(--radius-sm)", cursor: "pointer" } as React.CSSProperties,
+} as const;
+
+function domainFor(slug: string): string {
+ const map: Record = {
+ "google-workspace": "google.com",
+ "microsoft-365": "microsoft.com",
+ "okta-saml": "okta.com",
+ "azure-ad": "microsoft.com",
+ "microsoft-teams": "microsoft.com",
+ "aws-cost-explorer": "aws.amazon.com",
+ "gcp-billing": "cloud.google.com",
+ };
+ return map[slug] ?? `${slug.replace(/-/g, "")}.com`;
+}
+
+function formatSyncTime(iso: string): string {
+ const d = new Date(iso);
+ return d.toLocaleString("en-US", {
+ month: "short",
+ day: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ });
+}
+
+export function ManageDrawer({ integration, open, onClose, onDisconnected }: Props): JSX.Element {
+ const [scopes, setScopes] = useState(integration.scopes ?? integration.defaultScopes);
+ const [history, setHistory] = useState([]);
+ const [historyError, setHistoryError] = useState(null);
+ const [confirming, setConfirming] = useState(false);
+
+ useEffect(() => {
+ if (open) {
+ setScopes(integration.scopes ?? integration.defaultScopes);
+ setConfirming(false);
+ }
+ }, [open, integration.id, integration.scopes, integration.defaultScopes]);
+
+ useEffect(() => {
+ if (!open) return;
+ let cancelled = false;
+ fetch(`/v1/integrations/${encodeURIComponent(integration.id)}/syncs`, {
+ headers: { Authorization: `Bearer ${DEMO_BEARER_TOKEN}` },
+ })
+ .then((r) => {
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
+ return r.json() as Promise<{ syncs: SyncRecord[] }>;
+ })
+ .then(({ syncs }) => {
+ if (!cancelled) {
+ setHistory(syncs);
+ setHistoryError(null);
+ }
+ })
+ .catch((err) => {
+ if (!cancelled) setHistoryError(err instanceof Error ? err.message : "Couldn't load");
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [open, integration.id]);
+
+ function revokeScope(scope: string): void {
+ setScopes((prev) => prev.filter((s) => s !== scope));
+ }
+
+ async function confirmDisconnect(): Promise {
+ try {
+ const resp = await fetch(
+ `/v1/integrations/${encodeURIComponent(integration.id)}/disconnect`,
+ { method: "POST", headers: { Authorization: `Bearer ${DEMO_BEARER_TOKEN}` } },
+ );
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
+ const json = (await resp.json()) as { integration: IntegrationDto };
+ onDisconnected(json.integration);
+ onClose();
+ } catch {
+ setConfirming(false);
+ }
+ }
+
+ return (
+
+
+
+
+
{integration.name}
+ {integration.connectedAs &&
Connected as {integration.connectedAs}
}
+
+
+
+
+
Permissions ({scopes.length})
+
+ {scopes.length === 0 ? (
+
+ No scopes granted. Reconnect to restore access.
+
+ ) : (
+ scopes.map((scope, i) => (
+
+ {scope}
+
+
+ ))
+ )}
+
+
+
+
+
Sync history
+
+ {historyError && (
+
{historyError}
+ )}
+ {!historyError && history.length === 0 && (
+
No syncs yet.
+ )}
+ {history.map((s, i) => (
+
+ {formatSyncTime(s.startedAt)}
+ Synced {s.recordsScanned.toLocaleString()} records
+
+ {s.status}
+
+
+ ))}
+
+
+
+
+
Danger zone
+
+ {confirming && (
+
+
+ Disconnecting stops syncs and drops cached credentials. Records already
+ ingested are kept.
+
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/src/components/MaterialChangeCard.tsx b/apps/web/src/components/MaterialChangeCard.tsx
new file mode 100644
index 0000000..9b106d8
--- /dev/null
+++ b/apps/web/src/components/MaterialChangeCard.tsx
@@ -0,0 +1,327 @@
+import { useState } from "react";
+import { Archive, Clock, ArrowUpRight } from "lucide-react";
+import type { InboxItem } from "@unsyphn/shared";
+import { VendorLogo } from "./VendorLogo.js";
+
+interface Props {
+ item: InboxItem;
+ focused: boolean;
+ selected: boolean;
+ unread: boolean;
+ escalated: boolean;
+ showCheckbox: boolean;
+ isFirst: boolean;
+ isLast: boolean;
+ onClick: () => void;
+ onFocus: () => void;
+ onToggleSelect: () => void;
+ onSnooze: () => void;
+ onArchive: () => void;
+ onEscalate: () => void;
+}
+
+interface SevStyle { bg: string; fg: string; label: string }
+const SEVERITY_STYLE: Record = {
+ P1: { bg: "rgba(239,68,68,0.10)", fg: "#dc2626", label: "P1" },
+ P2: { bg: "rgba(245,158,11,0.10)", fg: "#b45309", label: "P2" },
+ P3: { bg: "rgba(100,116,139,0.10)", fg: "#475569", label: "P3" },
+};
+const SEV_DEFAULT: SevStyle = { bg: "rgba(100,116,139,0.10)", fg: "#475569", label: "P3" };
+
+function formatImpact(n: number | null): string {
+ if (n === null) return "";
+ if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(1)}M`;
+ if (n >= 1000) return `$${Math.round(n / 1000)}k`;
+ return `$${n}`;
+}
+
+function relativeTime(iso: string): string {
+ const diffMs = Date.now() - Date.parse(iso);
+ const diffMin = Math.floor(diffMs / 60_000);
+ const diffH = Math.floor(diffMin / 60);
+ const diffD = Math.floor(diffH / 24);
+ if (diffMin < 60) return `${Math.max(1, diffMin)}m`;
+ if (diffH < 24) return `${diffH}h`;
+ if (diffD === 1) return "1d";
+ if (diffD < 7) return `${diffD}d`;
+ const d = new Date(iso);
+ return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
+}
+
+export function MaterialChangeCard({
+ item,
+ focused,
+ selected,
+ unread,
+ escalated,
+ showCheckbox,
+ isFirst,
+ isLast,
+ onClick,
+ onFocus,
+ onToggleSelect,
+ onSnooze,
+ onArchive,
+ onEscalate,
+}: Props): JSX.Element {
+ const [hovered, setHovered] = useState(false);
+ const sev: SevStyle = SEVERITY_STYLE[item.severity] ?? SEV_DEFAULT;
+
+ const baseBg = selected
+ ? "rgba(94,106,210,0.06)"
+ : focused
+ ? "rgba(94,106,210,0.04)"
+ : hovered
+ ? "#f1f5f9"
+ : "#ffffff";
+
+ const rowStyle: React.CSSProperties = {
+ display: "flex",
+ alignItems: "center",
+ gap: 12,
+ minHeight: 68,
+ padding: "14px 24px",
+ borderBottom: isLast ? "none" : "1px solid rgba(15,23,42,0.06)",
+ borderTopLeftRadius: isFirst ? 8 : 0,
+ borderTopRightRadius: isFirst ? 8 : 0,
+ borderBottomLeftRadius: isLast ? 8 : 0,
+ borderBottomRightRadius: isLast ? 8 : 0,
+ background: baseBg,
+ cursor: "pointer",
+ outline: "none",
+ boxShadow: focused ? "inset 2px 0 0 var(--accent)" : "none",
+ transition: "background var(--dur-fast) var(--ease-out)",
+ position: "relative",
+ };
+
+ const showCheckboxFinal = showCheckbox || hovered || selected;
+ const showQuickActions = hovered;
+
+ return (
+ setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ onClick();
+ }
+ }}
+ >
+ {/* Unread blue dot */}
+
+
+ {/* Checkbox */}
+
e.stopPropagation()}
+ aria-label={`Select ${item.title}`}
+ style={{
+ width: 16,
+ height: 16,
+ margin: 0,
+ cursor: "pointer",
+ opacity: showCheckboxFinal ? 1 : 0,
+ transition: "opacity var(--dur-fast) var(--ease-out)",
+ flexShrink: 0,
+ }}
+ />
+
+ {/* 24px round vendor logo */}
+
+
+
+
+ {/* Vendor name (a bit emphasized) */}
+
+ {item.vendorName}
+
+
+ {/* Middle: title + one-line preview */}
+
+
+ {item.title}
+
+
+ {item.summary}
+
+
+
+ {/* Right gutter: quick actions OR meta */}
+ {showQuickActions ? (
+
+
{ e.stopPropagation(); onSnooze(); }}>
+
+
+
{ e.stopPropagation(); onArchive(); }}>
+
+
+
{ e.stopPropagation(); onEscalate(); }}>
+
+
+
+ ) : (
+
+ {escalated && (
+
+ ESCALATED
+
+ )}
+
+ {sev.label}
+
+
+ {formatImpact(item.dollarImpact)}
+
+
+ {relativeTime(item.occurredAt)}
+
+
+ )}
+
+ );
+}
+
+function QuickIcon({
+ label,
+ onClick,
+ children,
+}: {
+ label: string;
+ onClick: (e: React.MouseEvent) => void;
+ children: React.ReactNode;
+}): JSX.Element {
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/PageShell.tsx b/apps/web/src/components/PageShell.tsx
new file mode 100644
index 0000000..bf5cf17
--- /dev/null
+++ b/apps/web/src/components/PageShell.tsx
@@ -0,0 +1,91 @@
+import type { ReactNode } from "react";
+
+interface PageShellProps {
+ active?: "product" | "pricing" | "trust" | "demo" | "contact" | "docs" | null;
+ children: ReactNode;
+}
+
+const NAV_LINKS = [
+ { key: "product", label: "Product", href: "/" },
+ { key: "pricing", label: "Pricing", href: "/pricing" },
+ { key: "trust", label: "Trust", href: "/trust" },
+ { key: "docs", label: "Docs", href: "/docs" },
+] as const;
+
+export function PageShell({ active = null, children }: PageShellProps): JSX.Element {
+ return (
+
+ );
+}
+
+function PublicFooter(): JSX.Element {
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/RenegotiationPacket.tsx b/apps/web/src/components/RenegotiationPacket.tsx
new file mode 100644
index 0000000..91bd890
--- /dev/null
+++ b/apps/web/src/components/RenegotiationPacket.tsx
@@ -0,0 +1,308 @@
+import { useEffect, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import { X, Copy, Mail, FileText } from "lucide-react";
+import { DEMO_BEARER_TOKEN } from "../lib/api.js";
+
+type Tone = "firm" | "friendly" | "aggressive";
+
+interface Draft {
+ tone: Tone;
+ subject: string;
+ body: string;
+}
+
+interface PacketData {
+ vendorId: string;
+ vendorName: string;
+ currentSpend: number;
+ benchmarkRange: { low: number; high: number };
+ usagePct: number;
+ recoverableUsd: number;
+ drafts: Draft[];
+ talkingPoints: string[];
+}
+
+interface Props {
+ vendorId: string;
+ open: boolean;
+ onClose: () => void;
+}
+
+const TONES: Tone[] = ["firm", "friendly", "aggressive"];
+
+function fmtUsd(n: number): string {
+ if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(1)}M`;
+ if (n >= 1000) return `$${Math.round(n / 1000)}k`;
+ return `$${n}`;
+}
+
+const FOCUSABLE = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
+
+function Toast({ message }: { message: string }): JSX.Element {
+ return (
+
+ {message}
+
+ );
+}
+
+export function RenegotiationPacket({ vendorId, open, onClose }: Props): JSX.Element | null {
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [tone, setTone] = useState("firm");
+ const [editedSubject, setEditedSubject] = useState("");
+ const [editedBody, setEditedBody] = useState("");
+ const [toast, setToast] = useState(null);
+ const panelRef = useRef(null);
+
+ useEffect(() => {
+ if (!open) return;
+ setLoading(true);
+ setError(null);
+ setData(null);
+ setTone("firm");
+
+ const bearer = localStorage.getItem("unsyphn:bearer") ?? DEMO_BEARER_TOKEN;
+ fetch(`/v1/vendors/${encodeURIComponent(vendorId)}/renegotiation-packet`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${bearer}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ tone: "firm" }),
+ })
+ .then((r) => {
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
+ return r.json() as Promise;
+ })
+ .then((d) => {
+ setData(d);
+ const firmDraft = d.drafts.find((dr) => dr.tone === "firm") ?? d.drafts[0];
+ if (firmDraft) {
+ setEditedSubject(firmDraft.subject);
+ setEditedBody(firmDraft.body);
+ }
+ setLoading(false);
+ })
+ .catch(() => {
+ setError("Failed to load renegotiation packet. Try again.");
+ setLoading(false);
+ });
+ }, [open, vendorId]);
+
+ useEffect(() => {
+ if (!data) return;
+ const draft = data.drafts.find((d) => d.tone === tone) ?? data.drafts[0];
+ if (draft) {
+ setEditedSubject(draft.subject);
+ setEditedBody(draft.body);
+ }
+ }, [tone, data]);
+
+ useEffect(() => {
+ if (!open) return;
+ const handler = (e: KeyboardEvent) => {
+ if (e.key === "Escape") { e.preventDefault(); onClose(); return; }
+ if (e.key !== "Tab") return;
+ const panel = panelRef.current;
+ if (!panel) return;
+ const nodes = Array.from(panel.querySelectorAll(FOCUSABLE)).filter(
+ (el) => !el.hasAttribute("disabled"),
+ );
+ if (!nodes.length) return;
+ const first = nodes[0]!;
+ const last = nodes[nodes.length - 1]!;
+ if (e.shiftKey) {
+ if (document.activeElement === first) { e.preventDefault(); last.focus(); }
+ } else {
+ if (document.activeElement === last) { e.preventDefault(); first.focus(); }
+ }
+ };
+ document.addEventListener("keydown", handler);
+ return () => document.removeEventListener("keydown", handler);
+ }, [open, onClose]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ function showToast(msg: string): void {
+ setToast(msg);
+ setTimeout(() => setToast(null), 2000);
+ }
+
+ function copyEmail(): void {
+ void navigator.clipboard.writeText(`Subject: ${editedSubject}\n\n${editedBody}`);
+ showToast("Copied to clipboard");
+ }
+
+ function sendViaGmail(): void {
+ const href = `mailto:?subject=${encodeURIComponent(editedSubject)}&body=${encodeURIComponent(editedBody)}`;
+ window.open(href, "_blank", "noopener,noreferrer");
+ }
+
+ if (!open) return null;
+
+ const benchmarkSavingsPct = data
+ ? Math.round(((data.currentSpend - data.benchmarkRange.high) / data.currentSpend) * 100)
+ : 0;
+
+ return createPortal(
+ <>
+
+ { panelRef.current = el; }}
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby="reneg-title"
+ className="glass-strong slide-in-right"
+ style={{
+ position: "fixed",
+ top: 0,
+ right: 0,
+ bottom: 0,
+ width: "min(560px, 100vw)",
+ borderLeft: "1px solid var(--glass-border)",
+ zIndex: 401,
+ display: "flex",
+ flexDirection: "column",
+ }}
+ >
+ {/* Header */}
+
+
+ Renegotiation Packet{data ? ` · ${data.vendorName}` : ""}
+
+
+
+
+ {/* Body */}
+
+ {loading && (
+
+ Building packet...
+
+ )}
+
+ {error &&
{error}}
+
+ {data && (
+ <>
+ {/* Position summary */}
+
+
+ Current position
+
+
+ Spend: {fmtUsd(data.currentSpend)}/yr
+ ·
+ Benchmark: {fmtUsd(data.benchmarkRange.low)}–{fmtUsd(data.benchmarkRange.high)}
+ -{benchmarkSavingsPct}%
+ ·
+ Usage: {data.usagePct}%
+ ·
+ Recoverable: {fmtUsd(data.recoverableUsd)}
+
+
+
+ {/* Draft email */}
+
+
+
+ Drafted counter-offer
+
+
+ {TONES.map((t) => (
+
+ ))}
+
+
+
+
+ setEditedSubject(e.target.value)}
+ style={{ width: "100%", padding: "var(--space-3) var(--space-4)", border: "none", borderBottom: "1px solid var(--border)", fontSize: "var(--text-sm)", fontFamily: "var(--font-text)", color: "var(--text)", background: "var(--surface)", boxSizing: "border-box", outline: "none" }}
+ placeholder="Subject line"
+ />
+
+
+
+ {/* Talking points */}
+
+
+ Talking points
+
+
+ {data.talkingPoints.map((pt, i) => (
+ -
+ •
+ {pt}
+
+ ))}
+
+
+ >
+ )}
+
+
+ {/* Footer actions */}
+ {data && (
+
+
+
+
+
+ )}
+
+
+ {toast && }
+ >,
+ document.body,
+ );
+}
diff --git a/apps/web/src/components/RoleSwitcher.tsx b/apps/web/src/components/RoleSwitcher.tsx
new file mode 100644
index 0000000..d515beb
--- /dev/null
+++ b/apps/web/src/components/RoleSwitcher.tsx
@@ -0,0 +1,169 @@
+import { useEffect, useRef, useState } from "react";
+import { ChevronDown, ChevronUp, Check } from "lucide-react";
+import { ALL_ROLES, ROLE_LABELS, useRole } from "../lib/role.js";
+import type { Role } from "../lib/role.js";
+
+export function RoleSwitcher(): JSX.Element {
+ const [role, setRole] = useRole();
+ const [open, setOpen] = useState(false);
+ const triggerRef = useRef(null);
+ const menuRef = useRef(null);
+ const [focusedIndex, setFocusedIndex] = useState(-1);
+
+ const close = () => {
+ setOpen(false);
+ setFocusedIndex(-1);
+ triggerRef.current?.focus();
+ };
+
+ const select = (r: Role) => {
+ setRole(r);
+ close();
+ };
+
+ useEffect(() => {
+ if (!open) return;
+ const onMouseDown = (e: MouseEvent) => {
+ if (
+ triggerRef.current?.contains(e.target as Node) ||
+ menuRef.current?.contains(e.target as Node)
+ ) return;
+ close();
+ };
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ e.preventDefault();
+ close();
+ return;
+ }
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ setFocusedIndex(i => Math.min(i + 1, ALL_ROLES.length - 1));
+ return;
+ }
+ if (e.key === "ArrowUp") {
+ e.preventDefault();
+ setFocusedIndex(i => Math.max(i - 1, 0));
+ return;
+ }
+ if (e.key === "Enter" && focusedIndex >= 0) {
+ e.preventDefault();
+ const picked: Role | undefined = ALL_ROLES[focusedIndex];
+ if (picked !== undefined) select(picked);
+ }
+ };
+ document.addEventListener("mousedown", onMouseDown);
+ document.addEventListener("keydown", onKeyDown);
+ return () => {
+ document.removeEventListener("mousedown", onMouseDown);
+ document.removeEventListener("keydown", onKeyDown);
+ };
+ }, [open, focusedIndex]);
+
+ useEffect(() => {
+ if (!open) return;
+ const idx = ALL_ROLES.indexOf(role);
+ setFocusedIndex(idx >= 0 ? idx : 0);
+ }, [open]);
+
+ useEffect(() => {
+ if (!open || focusedIndex < 0) return;
+ const item = menuRef.current?.children[focusedIndex] as HTMLElement | undefined;
+ item?.focus();
+ }, [focusedIndex, open]);
+
+ const optionId = (r: Role) => `role-option-${r}`;
+ const menuId = "role-switcher-listbox";
+
+ return (
+
+
+
+ {open && (
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/SeverityBadge.tsx b/apps/web/src/components/SeverityBadge.tsx
new file mode 100644
index 0000000..2187a3e
--- /dev/null
+++ b/apps/web/src/components/SeverityBadge.tsx
@@ -0,0 +1,19 @@
+import type { Severity } from "@unsyphn/shared";
+
+interface Props {
+ severity: Severity;
+}
+
+const CLASS_MAP: Record = {
+ P1: "badge badge-danger",
+ P2: "badge badge-warning",
+ P3: "badge badge-success",
+};
+
+export function SeverityBadge({ severity }: Props): JSX.Element {
+ return (
+
+ · {severity}
+
+ );
+}
diff --git a/apps/web/src/components/SkeletonRow.tsx b/apps/web/src/components/SkeletonRow.tsx
new file mode 100644
index 0000000..6d1d844
--- /dev/null
+++ b/apps/web/src/components/SkeletonRow.tsx
@@ -0,0 +1,153 @@
+import type { CSSProperties } from "react";
+
+interface SkeletonRowProps {
+ /** Outer container height in px. */
+ height?: number;
+ /** Number of horizontal text bars rendered (vertically stacked). */
+ bars?: number;
+ /** Show a circular avatar/icon on the left. */
+ avatar?: boolean;
+ /** Show a small trailing bar on the right. */
+ trail?: boolean;
+ /** Extra container style. */
+ style?: CSSProperties;
+}
+
+/**
+ * Glass-soft skeleton row. Pairs the `.skeleton` shimmer (defined in
+ * polish.css) with the same lift/blur surface used elsewhere in-app.
+ */
+export function SkeletonRow({
+ height = 72,
+ bars = 2,
+ avatar = true,
+ trail = true,
+ style,
+}: SkeletonRowProps): JSX.Element {
+ return (
+
+ {avatar && (
+
+ )}
+
+ {Array.from({ length: bars }).map((_, i) => (
+
+ ))}
+
+ {trail && (
+
+ )}
+
+ );
+}
+
+interface SkeletonCardProps {
+ /** Card height. */
+ height?: number;
+ /** Number of horizontal text bars rendered in the body. */
+ bars?: number;
+ style?: CSSProperties;
+}
+
+/**
+ * Larger card-shaped skeleton — for Reports grid, vendor detail cards.
+ */
+export function SkeletonCard({
+ height = 180,
+ bars = 4,
+ style,
+}: SkeletonCardProps): JSX.Element {
+ return (
+
+
+ {Array.from({ length: bars }).map((_, i) => (
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/src/components/VendorCard.tsx b/apps/web/src/components/VendorCard.tsx
new file mode 100644
index 0000000..f45a4a6
--- /dev/null
+++ b/apps/web/src/components/VendorCard.tsx
@@ -0,0 +1,157 @@
+import { VendorLogo } from "./VendorLogo.js";
+
+export type Posture = "fresh" | "expiring" | "stale" | "risk" | "watch" | "ok";
+
+export interface VendorCardData {
+ id: string;
+ name: string;
+ domain: string;
+ tier: 1 | 2 | 3;
+ posture: Posture;
+ renewsAt: string;
+ ownerInitial: string;
+ ownerEmail: string;
+}
+
+function postureLabel(p: Posture): string {
+ if (p === "fresh" || p === "ok") return "fresh";
+ if (p === "expiring" || p === "watch") return "expiring";
+ return "stale";
+}
+
+function postureBadgeClass(p: Posture): string {
+ if (p === "fresh" || p === "ok") return "badge badge-success";
+ if (p === "expiring" || p === "watch") return "badge badge-warning";
+ return "badge badge-danger";
+}
+
+function daysUntil(dateStr: string): number {
+ const now = Date.now();
+ const then = new Date(dateStr).getTime();
+ return Math.round((then - now) / 86_400_000);
+}
+
+function formatRenewal(dateStr: string): string {
+ const d = new Date(dateStr);
+ const formatted = d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
+ const days = daysUntil(dateStr);
+ const suffix = days > 0 ? ` (in ${days}d)` : ` (${Math.abs(days)}d ago)`;
+ return `Renews ${formatted}${suffix}`;
+}
+
+function tierLabel(tier: number): string {
+ if (tier === 1) return "Tier 1";
+ if (tier === 2) return "Tier 2";
+ return "Tier 3";
+}
+
+interface VendorCardProps {
+ vendor: VendorCardData;
+}
+
+export function VendorCard({ vendor }: VendorCardProps): JSX.Element {
+ const handleClick = () => {
+ window.location.assign(`/app/vendor/${vendor.id}`);
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ handleClick();
+ }
+ };
+
+ return (
+
+ {/* Top row: logo + name + tier badge */}
+
+
+
+
+ {vendor.name}
+
+ {tierLabel(vendor.tier)}
+
+
+
+ {/* Domain */}
+
+ {vendor.domain}
+
+
+ {/* Posture chip */}
+
+ {postureLabel(vendor.posture)}
+
+
+ {/* Renewal date */}
+
+ {formatRenewal(vendor.renewsAt)}
+
+
+ {/* Owner */}
+
+
+ {vendor.ownerInitial}
+
+
+ {vendor.ownerEmail}
+
+
+
+ );
+}
diff --git a/apps/web/src/components/VendorLogo.tsx b/apps/web/src/components/VendorLogo.tsx
new file mode 100644
index 0000000..6ff14e3
--- /dev/null
+++ b/apps/web/src/components/VendorLogo.tsx
@@ -0,0 +1,71 @@
+import { useState } from "react";
+import { simpleIconsUrl, brandfetchUrl, monogramFor } from "../lib/logos.js";
+
+interface Props {
+ name: string;
+ domain?: string;
+ size?: number;
+}
+
+export function VendorLogo({ name, domain, size = 32 }: Props) {
+ const [stage, setStage] = useState<0 | 1 | 2>(0);
+
+ const containerStyle: React.CSSProperties = {
+ width: size,
+ height: size,
+ borderRadius: "var(--radius-sm)",
+ border: "1px solid var(--border)",
+ background: "var(--surface)",
+ display: "inline-flex",
+ alignItems: "center",
+ justifyContent: "center",
+ overflow: "hidden",
+ flexShrink: 0,
+ };
+
+ if (stage === 0) {
+ return (
+
+
setStage(domain ? 1 : 2)}
+ style={{ objectFit: "contain" }}
+ />
+
+ );
+ }
+
+ if (stage === 1 && domain) {
+ return (
+
+
setStage(2)}
+ style={{ objectFit: "contain" }}
+ />
+
+ );
+ }
+
+ const { initials, bg, fg } = monogramFor(name);
+ return (
+
+ {initials}
+
+ );
+}
diff --git a/apps/web/src/components/findings/FindingDrawer.tsx b/apps/web/src/components/findings/FindingDrawer.tsx
new file mode 100644
index 0000000..13222fe
--- /dev/null
+++ b/apps/web/src/components/findings/FindingDrawer.tsx
@@ -0,0 +1,195 @@
+import { ExternalLink, AlertCircle, Eye, CheckCircle2 } from "lucide-react";
+import type { Finding, FindingState } from "../../lib/api.js";
+import { Drawer } from "../Drawer.js";
+
+interface Props {
+ finding: Finding | null;
+ open: boolean;
+ busy: boolean;
+ onClose: () => void;
+ onChangeState: (next: FindingState) => void;
+}
+
+function fmtDate(iso: string): string {
+ try {
+ return new Date(iso).toLocaleString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ });
+ } catch {
+ return iso;
+ }
+}
+
+function sourceLinkLabel(type: Finding["type"]): string {
+ if (type === "change") return "Open ChangeReport";
+ if (type === "subprocessor") return "Open sub-processor heatmap";
+ if (type === "spend") return "Open vendor spend tab";
+ if (type === "compliance") return "Open vendor detail";
+ return "Open source";
+}
+
+function Section({ label, children }: { label: string; children: React.ReactNode }): JSX.Element {
+ return (
+
+
+ {label}
+
+ {children}
+
+ );
+}
+
+export function FindingDrawer({ finding, open, busy, onClose, onChangeState }: Props): JSX.Element | null {
+ if (!finding) return null;
+ const severityClass =
+ finding.severity === "P1"
+ ? "badge badge-danger"
+ : finding.severity === "P2"
+ ? "badge badge-warning"
+ : "badge badge-neutral";
+
+ return (
+
+
+
+ {finding.severity}
+
+ {finding.type}
+
+
+ {finding.state.replace("-", " ")}
+
+
+
+
+ {finding.title}
+
+
+ {finding.summary}
+
+
+
+
+
+
+ {fmtDate(finding.detectedAt)}
+
+
+
+ {finding.lensTags.length > 0 && (
+
+
+ {finding.lensTags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+ )}
+
+ {finding.sourceUrl && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/findings/FindingsFilters.tsx b/apps/web/src/components/findings/FindingsFilters.tsx
new file mode 100644
index 0000000..411b9c2
--- /dev/null
+++ b/apps/web/src/components/findings/FindingsFilters.tsx
@@ -0,0 +1,119 @@
+import type { FindingState, FindingType } from "../../lib/api.js";
+
+export type SeverityFilter = "P1" | "P2" | "P3";
+
+interface Props {
+ typeFilter: FindingType | "all";
+ severityFilter: SeverityFilter | "all";
+ stateFilter: FindingState | "all";
+ onTypeChange: (next: FindingType | "all") => void;
+ onSeverityChange: (next: SeverityFilter | "all") => void;
+ onStateChange: (next: FindingState | "all") => void;
+}
+
+const TYPE_CHIPS: Array<{ value: FindingType | "all"; label: string }> = [
+ { value: "all", label: "All" },
+ { value: "change", label: "Change" },
+ { value: "compliance", label: "Compliance" },
+ { value: "subprocessor", label: "Sub-processor" },
+ { value: "spend", label: "Spend" },
+ { value: "security", label: "Security" },
+];
+
+const SEVERITY_CHIPS: Array<{ value: SeverityFilter | "all"; label: string }> = [
+ { value: "all", label: "All severities" },
+ { value: "P1", label: "P1" },
+ { value: "P2", label: "P2" },
+ { value: "P3", label: "P3" },
+];
+
+const STATE_CHIPS: Array<{ value: FindingState | "all"; label: string }> = [
+ { value: "all", label: "All states" },
+ { value: "open", label: "Open" },
+ { value: "under-review", label: "Under review" },
+ { value: "resolved", label: "Resolved" },
+];
+
+function ChipGroup({
+ label,
+ chips,
+ value,
+ onChange,
+}: {
+ label: string;
+ chips: ReadonlyArray<{ value: T; label: string }>;
+ value: T;
+ onChange: (next: T) => void;
+}): JSX.Element {
+ return (
+
+
+ {label}
+
+ {chips.map((chip) => (
+
+ ))}
+
+ );
+}
+
+export function FindingsFilters({
+ typeFilter,
+ severityFilter,
+ stateFilter,
+ onTypeChange,
+ onSeverityChange,
+ onStateChange,
+}: Props): JSX.Element {
+ return (
+
+
+ label="Type"
+ chips={TYPE_CHIPS}
+ value={typeFilter}
+ onChange={onTypeChange}
+ />
+
+ label="Severity"
+ chips={SEVERITY_CHIPS}
+ value={severityFilter}
+ onChange={onSeverityChange}
+ />
+
+ label="State"
+ chips={STATE_CHIPS}
+ value={stateFilter}
+ onChange={onStateChange}
+ />
+
+ );
+}
diff --git a/apps/web/src/components/findings/FindingsStats.tsx b/apps/web/src/components/findings/FindingsStats.tsx
new file mode 100644
index 0000000..8776ff1
--- /dev/null
+++ b/apps/web/src/components/findings/FindingsStats.tsx
@@ -0,0 +1,88 @@
+import type { Finding, FindingType } from "../../lib/api.js";
+import { CountUp } from "../CountUp.js";
+
+const TYPE_LABELS: Record = {
+ change: "Change",
+ compliance: "Compliance",
+ subprocessor: "Sub-processor",
+ spend: "Spend",
+ security: "Security",
+};
+
+interface Props {
+ findings: ReadonlyArray;
+}
+
+const intFmt = (n: number): string => Math.round(n).toString();
+
+function Stat({ label, value, accent }: { label: string; value: number; accent?: boolean }): JSX.Element {
+ return (
+
+
+ {label}
+
+
+
+
+
+ );
+}
+
+export function FindingsStats({ findings }: Props): JSX.Element {
+ const open = findings.filter((f) => f.state !== "resolved");
+ const p1 = findings.filter((f) => f.severity === "P1" && f.state !== "resolved").length;
+
+ const byType: Record = {
+ change: 0,
+ compliance: 0,
+ subprocessor: 0,
+ spend: 0,
+ security: 0,
+ };
+ for (const f of open) byType[f.type] += 1;
+
+ return (
+
+
+ 0} />
+ {(Object.entries(byType) as Array<[FindingType, number]>).map(([type, count]) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/web/src/components/findings/FindingsTable.tsx b/apps/web/src/components/findings/FindingsTable.tsx
new file mode 100644
index 0000000..ed2b04b
--- /dev/null
+++ b/apps/web/src/components/findings/FindingsTable.tsx
@@ -0,0 +1,180 @@
+import type { Finding } from "../../lib/api.js";
+
+interface Props {
+ findings: ReadonlyArray;
+ onOpen: (finding: Finding) => void;
+}
+
+const TYPE_LABEL: Record = {
+ change: "Change",
+ compliance: "Compliance",
+ subprocessor: "Sub-processor",
+ spend: "Spend",
+ security: "Security",
+};
+
+const STATE_LABEL: Record = {
+ open: "Open",
+ "under-review": "Under review",
+ resolved: "Resolved",
+};
+
+function severityClass(sev: Finding["severity"]): string {
+ if (sev === "P1") return "badge badge-danger";
+ if (sev === "P2") return "badge badge-warning";
+ return "badge badge-neutral";
+}
+
+function stateClass(state: Finding["state"]): string {
+ if (state === "open") return "badge badge-warning";
+ if (state === "under-review") return "badge badge-accent";
+ return "badge badge-neutral";
+}
+
+function fmtDate(iso: string): string {
+ try {
+ return new Date(iso).toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ });
+ } catch {
+ return iso;
+ }
+}
+
+export function FindingsTable({ findings, onOpen }: Props): JSX.Element {
+ return (
+
+
+
+
+ | Severity |
+ Title |
+ Vendor |
+ Type |
+ Detected |
+ State |
+ Action |
+
+
+
+ {findings.map((f) => (
+ onOpen(f)}
+ >
+ |
+ {f.severity}
+ |
+
+
+ |
+
+ e.stopPropagation()}
+ style={{
+ color: "var(--text-strong)",
+ textDecoration: "none",
+ fontWeight: 500,
+ }}
+ >
+ {f.vendorName}
+
+ |
+ {TYPE_LABEL[f.type]} |
+ {fmtDate(f.detectedAt)} |
+
+ {STATE_LABEL[f.state]}
+ |
+
+
+ |
+
+ ))}
+
+
+
+ );
+}
+
+function Th({ children, align }: { children: React.ReactNode; align?: "right" }): JSX.Element {
+ return (
+
+ {children}
+ |
+ );
+}
+
+function Td({
+ children,
+ align,
+ style,
+}: {
+ children: React.ReactNode;
+ align?: "right";
+ style?: React.CSSProperties;
+}): JSX.Element {
+ return (
+
+ {children}
+ |
+ );
+}
diff --git a/apps/web/src/components/inbox/InboxEmptyState.tsx b/apps/web/src/components/inbox/InboxEmptyState.tsx
new file mode 100644
index 0000000..ea6fb9d
--- /dev/null
+++ b/apps/web/src/components/inbox/InboxEmptyState.tsx
@@ -0,0 +1,37 @@
+import { ROLE_LABELS, type Role } from "../../lib/role.js";
+
+interface InboxEmptyStateProps {
+ role: Role;
+ query: string;
+}
+
+export function InboxEmptyState({ role, query }: InboxEmptyStateProps): JSX.Element {
+ const emptyCopy = `Nothing for ${ROLE_LABELS[role]} today — try a different lens or J to switch.`;
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/inbox/InboxFilterRow.tsx b/apps/web/src/components/inbox/InboxFilterRow.tsx
new file mode 100644
index 0000000..0a1de40
--- /dev/null
+++ b/apps/web/src/components/inbox/InboxFilterRow.tsx
@@ -0,0 +1,96 @@
+import { useRef } from "react";
+import { Search } from "lucide-react";
+
+type FilterKind = "all" | "changes" | "renewals" | "unused-seats";
+type SeverityFilter = "all" | "P1" | "P2";
+
+interface InboxFilterRowProps {
+ filter: FilterKind;
+ severity: SeverityFilter;
+ query: string;
+ onFilterChange: (value: FilterKind) => void;
+ onSeverityChange: (value: SeverityFilter) => void;
+ onQueryChange: (value: string) => void;
+}
+
+const FILTER_CHIPS: Array<{ label: string; value: FilterKind }> = [
+ { label: "All", value: "all" },
+ { label: "Changes", value: "changes" },
+ { label: "Renewals", value: "renewals" },
+ { label: "Unused seats", value: "unused-seats" },
+];
+
+export function InboxFilterRow({
+ filter,
+ severity,
+ query,
+ onFilterChange,
+ onSeverityChange,
+ onQueryChange,
+}: InboxFilterRowProps): JSX.Element {
+ const searchRef = useRef(null);
+
+ return (
+
+ {FILTER_CHIPS.map(({ label, value }) => (
+
+ ))}
+
+
+
+
+
+
+ onQueryChange(e.target.value)}
+ style={{ height: 28, paddingLeft: 26, fontSize: "var(--text-xs)" }}
+ aria-label="Search inbox"
+ />
+
+
+
+ );
+}
diff --git a/apps/web/src/components/inbox/InboxKeyHints.tsx b/apps/web/src/components/inbox/InboxKeyHints.tsx
new file mode 100644
index 0000000..ef63cc3
--- /dev/null
+++ b/apps/web/src/components/inbox/InboxKeyHints.tsx
@@ -0,0 +1,43 @@
+const HINTS: Array<[string, string]> = [
+ ["J/K", "navigate"],
+ ["Enter", "open"],
+ ["R", "open"],
+ ["E", "resolve"],
+ ["S", "snooze 48h"],
+ ["X", "select"],
+];
+
+export function InboxKeyHints(): JSX.Element {
+ return (
+
+ {HINTS.map(([key, desc]) => (
+
+
+ {key}
+ {" "}
+ {desc}
+
+ ))}
+
+ );
+}
diff --git a/apps/web/src/components/inbox/InboxList.tsx b/apps/web/src/components/inbox/InboxList.tsx
new file mode 100644
index 0000000..60d967f
--- /dev/null
+++ b/apps/web/src/components/inbox/InboxList.tsx
@@ -0,0 +1,106 @@
+import type { InboxItem } from "@unsyphn/shared";
+import { MaterialChangeCard } from "../MaterialChangeCard.js";
+
+interface DayGroup {
+ bucket: string;
+ label: string;
+ items: InboxItem[];
+}
+
+interface InboxListProps {
+ dayGroups: DayGroup[];
+ focusedIndex: number;
+ selectedIds: Set;
+ readIds: Set;
+ escalatedIds: Set;
+ /** Called with the global flat index and the ref element for keyboard nav. */
+ registerRowRef: (idx: number, el: HTMLDivElement | null) => void;
+ onFocus: (idx: number) => void;
+ onClickItem: (item: InboxItem) => void;
+ onToggleSelect: (id: string) => void;
+ onSnooze: (item: InboxItem) => void;
+ onArchive: (item: InboxItem) => void;
+ onEscalate: (item: InboxItem) => void;
+ /** Start offset for flat index counting — must match parent's accumulated count. */
+ flatIndexOffset: number;
+}
+
+export function InboxList({
+ dayGroups,
+ focusedIndex,
+ selectedIds,
+ readIds,
+ escalatedIds,
+ registerRowRef,
+ onFocus,
+ onClickItem,
+ onToggleSelect,
+ onSnooze,
+ onArchive,
+ onEscalate,
+ flatIndexOffset,
+}: InboxListProps): JSX.Element {
+ let localFlatIndex = flatIndexOffset - 1;
+
+ return (
+
+ {dayGroups.map((group) => (
+
+
+ {group.label} · {group.items.length}
+
+
+ {group.items.map((item, idxInGroup) => {
+ localFlatIndex += 1;
+ const idx = localFlatIndex;
+ const unread = item.state === "new" && !readIds.has(item.id);
+ const isFirst = idxInGroup === 0;
+ const isLast = idxInGroup === group.items.length - 1;
+ return (
+
registerRowRef(idx, el as HTMLDivElement | null)}
+ >
+ 0}
+ isFirst={isFirst}
+ isLast={isLast}
+ onClick={() => onClickItem(item)}
+ onFocus={() => onFocus(idx)}
+ onToggleSelect={() => onToggleSelect(item.id)}
+ onSnooze={() => onSnooze(item)}
+ onArchive={() => onArchive(item)}
+ onEscalate={() => onEscalate(item)}
+ />
+
+ );
+ })}
+
+
+ ))}
+
+ );
+}
diff --git a/apps/web/src/components/inbox/InboxSkeletonRow.tsx b/apps/web/src/components/inbox/InboxSkeletonRow.tsx
new file mode 100644
index 0000000..d1f7c58
--- /dev/null
+++ b/apps/web/src/components/inbox/InboxSkeletonRow.tsx
@@ -0,0 +1,28 @@
+export function InboxSkeletonRow(): JSX.Element {
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/inbox/inboxApi.ts b/apps/web/src/components/inbox/inboxApi.ts
new file mode 100644
index 0000000..f87a456
--- /dev/null
+++ b/apps/web/src/components/inbox/inboxApi.ts
@@ -0,0 +1,19 @@
+import { DEMO_BEARER_TOKEN } from "../../lib/api.js";
+
+export async function postLifecycle(
+ id: string,
+ action: "acknowledge" | "snooze" | "resolve",
+ body: Record,
+): Promise {
+ const resp = await fetch(`/v1/changes/${encodeURIComponent(id)}/${action}`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${DEMO_BEARER_TOKEN}`,
+ },
+ body: JSON.stringify(body),
+ });
+ if (!resp.ok) {
+ throw new Error(`Request failed (${resp.status})`);
+ }
+}
diff --git a/apps/web/src/components/portfolio/BulkActionBar.tsx b/apps/web/src/components/portfolio/BulkActionBar.tsx
new file mode 100644
index 0000000..aea320a
--- /dev/null
+++ b/apps/web/src/components/portfolio/BulkActionBar.tsx
@@ -0,0 +1,252 @@
+import { useState } from "react";
+import { ShieldCheck, FileText, Users, Tag, X } from "lucide-react";
+import type { TeamMember } from "../../lib/api.js";
+
+interface Props {
+ count: number;
+ owners: ReadonlyArray;
+ onAssignOwner: (ownerId: string) => Promise | void;
+ onChangeTier: (tier: 1 | 2 | 3) => Promise | void;
+ onGeneratePackets: () => void;
+ onShareWithAuditor: () => void;
+ onClear: () => void;
+}
+
+export function BulkActionBar({
+ count,
+ owners,
+ onAssignOwner,
+ onChangeTier,
+ onGeneratePackets,
+ onShareWithAuditor,
+ onClear,
+}: Props): JSX.Element | null {
+ const [ownerOpen, setOwnerOpen] = useState(false);
+ const [tierOpen, setTierOpen] = useState(false);
+ const [busy, setBusy] = useState(false);
+
+ if (count === 0) return null;
+
+ const handleOwner = async (id: string) => {
+ setOwnerOpen(false);
+ setBusy(true);
+ try {
+ await onAssignOwner(id);
+ } finally {
+ setBusy(false);
+ }
+ };
+ const handleTier = async (t: 1 | 2 | 3) => {
+ setTierOpen(false);
+ setBusy(true);
+ try {
+ await onChangeTier(t);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+ {count} selected
+
+
+ ·
+
+
+
}
+ disabled={busy}
+ >
+ {owners.map((o) => (
+
+ ))}
+
+
+ }
+ disabled={busy}
+ >
+
+
+
+
+
+
+ Renegotiation packets
+
+
+
+ Share with auditor
+
+
+
+
+ );
+}
+
+function BarButton({
+ children,
+ onClick,
+ disabled,
+}: {
+ children: React.ReactNode;
+ onClick: () => void;
+ disabled?: boolean;
+}): JSX.Element {
+ return (
+
+ );
+}
+
+interface MenuProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ label: string;
+ icon: React.ReactNode;
+ disabled?: boolean;
+ children: React.ReactNode;
+}
+
+function Menu({ open, onOpenChange, label, icon, disabled, children }: MenuProps): JSX.Element {
+ return (
+
+
+ {open && (
+
+ {children}
+
+ )}
+
+ );
+}
+
+function MenuItem({ children, onClick }: { children: React.ReactNode; onClick: () => void }): JSX.Element {
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/portfolio/NewVendorDrawer.tsx b/apps/web/src/components/portfolio/NewVendorDrawer.tsx
new file mode 100644
index 0000000..a3c332e
--- /dev/null
+++ b/apps/web/src/components/portfolio/NewVendorDrawer.tsx
@@ -0,0 +1,571 @@
+import { useEffect, useMemo, useState } from "react";
+import type { DataClass, VendorCreateBody, VendorTier } from "@unsyphn/shared";
+import { Drawer } from "../Drawer.js";
+import { createVendor, type TeamMember } from "../../lib/api.js";
+import { ApiError } from "../../lib/api.js";
+
+export type VendorPosture = "ok" | "watch" | "risk";
+
+export interface NewVendorDrawerInitial {
+ name?: string;
+ homepageUrl?: string;
+ category?: string;
+ tier?: VendorTier;
+ posture?: VendorPosture;
+ ownerId?: string;
+ annualSpendUsd?: number;
+ seatCount?: number;
+ renewalDate?: string;
+ dataClasses?: DataClass[];
+ notes?: string;
+}
+
+interface Props {
+ open: boolean;
+ onClose: () => void;
+ onCreated: (vendorId: string) => void;
+ members: ReadonlyArray;
+ categoryOptions: ReadonlyArray;
+ initial?: NewVendorDrawerInitial;
+}
+
+interface FormState {
+ name: string;
+ homepageUrl: string;
+ category: string;
+ tier: VendorTier;
+ posture: VendorPosture;
+ ownerId: string;
+ annualSpendUsd: string;
+ seatCount: string;
+ renewalDate: string;
+ dataClasses: DataClass[];
+ notes: string;
+}
+
+const DEFAULT_CATEGORIES: ReadonlyArray = [
+ "productivity",
+ "payments",
+ "infrastructure",
+ "devtools",
+ "analytics",
+ "communication",
+ "security",
+ "design",
+ "crm",
+ "hr",
+ "database",
+ "observability",
+ "support",
+ "marketing",
+ "automation",
+];
+
+const DATA_CLASSES: ReadonlyArray<{ value: DataClass; label: string }> = [
+ { value: "pii", label: "PII" },
+ { value: "phi", label: "PHI" },
+ { value: "financial", label: "Financial" },
+ { value: "content", label: "Content" },
+];
+
+function emptyForm(initial: NewVendorDrawerInitial | undefined, fallbackOwner: string): FormState {
+ return {
+ name: initial?.name ?? "",
+ homepageUrl: initial?.homepageUrl ?? "",
+ category: initial?.category ?? "productivity",
+ tier: initial?.tier ?? 2,
+ posture: initial?.posture ?? "ok",
+ ownerId: initial?.ownerId ?? fallbackOwner,
+ annualSpendUsd:
+ initial?.annualSpendUsd !== undefined ? String(initial.annualSpendUsd) : "",
+ seatCount: initial?.seatCount !== undefined ? String(initial.seatCount) : "",
+ renewalDate: initial?.renewalDate ?? "",
+ dataClasses: initial?.dataClasses ?? [],
+ notes: initial?.notes ?? "",
+ };
+}
+
+interface FieldErrors {
+ name?: string;
+ homepageUrl?: string;
+ annualSpendUsd?: string;
+ seatCount?: string;
+ renewalDate?: string;
+}
+
+function validate(form: FormState): FieldErrors {
+ const errors: FieldErrors = {};
+ if (form.name.trim().length < 2) {
+ errors.name = "Vendor name must be at least 2 characters.";
+ }
+ const url = form.homepageUrl.trim();
+ if (!/^https?:\/\/.+\..+/i.test(url)) {
+ errors.homepageUrl = "Homepage must be a valid http(s) URL.";
+ }
+ if (form.annualSpendUsd.trim() !== "") {
+ const n = Number(form.annualSpendUsd);
+ if (!Number.isFinite(n) || n < 0) {
+ errors.annualSpendUsd = "Annual spend must be 0 or greater.";
+ }
+ }
+ if (form.seatCount.trim() !== "") {
+ const n = Number(form.seatCount);
+ if (!Number.isFinite(n) || n <= 0 || !Number.isInteger(n)) {
+ errors.seatCount = "Seat count must be a positive whole number.";
+ }
+ }
+ if (form.renewalDate.trim() !== "" && !/^\d{4}-\d{2}-\d{2}$/.test(form.renewalDate)) {
+ errors.renewalDate = "Renewal date must be YYYY-MM-DD.";
+ }
+ // Contract is all-or-nothing per VendorContractSchema. If user fills any
+ // of the three, require all three.
+ const anyContract = !!(form.annualSpendUsd || form.seatCount || form.renewalDate);
+ const fullContract = !!(form.annualSpendUsd && form.seatCount && form.renewalDate);
+ if (anyContract && !fullContract) {
+ if (!form.annualSpendUsd) errors.annualSpendUsd ??= "Required when seat count or renewal is set.";
+ if (!form.seatCount) errors.seatCount ??= "Required when spend or renewal is set.";
+ if (!form.renewalDate) errors.renewalDate ??= "Required when spend or seat count is set.";
+ }
+ return errors;
+}
+
+const LABEL_STYLE: React.CSSProperties = {
+ fontSize: 11,
+ fontWeight: 600,
+ textTransform: "uppercase",
+ letterSpacing: "0.06em",
+ color: "#64748b",
+ display: "block",
+ marginBottom: 6,
+};
+
+const INPUT_STYLE: React.CSSProperties = {
+ width: "100%",
+ padding: "8px 10px",
+ fontSize: 13,
+ border: "1px solid rgba(15,23,42,0.12)",
+ borderRadius: 8,
+ background: "#ffffff",
+ color: "#0f172a",
+ fontFamily: "var(--font-text)",
+};
+
+const ERROR_STYLE: React.CSSProperties = {
+ marginTop: 4,
+ fontSize: 12,
+ color: "#dc2626",
+};
+
+export function NewVendorDrawer({
+ open,
+ onClose,
+ onCreated,
+ members,
+ categoryOptions,
+ initial,
+}: Props): JSX.Element {
+ const fallbackOwner = useMemo(() => {
+ if (members.some((m) => m.id === "usr_priya")) return "usr_priya";
+ return members[0]?.id ?? "usr_priya";
+ }, [members]);
+
+ const [form, setForm] = useState(() => emptyForm(initial, fallbackOwner));
+ const [errors, setErrors] = useState({});
+ const [submitting, setSubmitting] = useState(false);
+ const [serverError, setServerError] = useState(null);
+ const [missingUrls, setMissingUrls] = useState([]);
+
+ useEffect(() => {
+ if (open) {
+ setForm(emptyForm(initial, fallbackOwner));
+ setErrors({});
+ setServerError(null);
+ setMissingUrls([]);
+ }
+ }, [open, initial, fallbackOwner]);
+
+ const categories = useMemo(() => {
+ const set = new Set([...DEFAULT_CATEGORIES, ...categoryOptions]);
+ return [...set].sort();
+ }, [categoryOptions]);
+
+ const updateField = (key: K, value: FormState[K]) => {
+ setForm((p) => ({ ...p, [key]: value }));
+ };
+
+ const toggleDataClass = (cls: DataClass) => {
+ setForm((p) => {
+ const has = p.dataClasses.includes(cls);
+ return {
+ ...p,
+ dataClasses: has ? p.dataClasses.filter((c) => c !== cls) : [...p.dataClasses, cls],
+ };
+ });
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ const fieldErrors = validate(form);
+ setErrors(fieldErrors);
+ if (Object.keys(fieldErrors).length > 0) return;
+
+ setSubmitting(true);
+ setServerError(null);
+ setMissingUrls([]);
+
+ const body: VendorCreateBody = {
+ name: form.name.trim(),
+ homepageUrl: form.homepageUrl.trim(),
+ ownerId: form.ownerId,
+ tier: form.tier,
+ dataClasses: form.dataClasses,
+ };
+ if (form.annualSpendUsd && form.seatCount && form.renewalDate) {
+ body.contract = {
+ renewsAt: form.renewalDate,
+ annualSpendUsd: Number(form.annualSpendUsd),
+ seatCount: Number(form.seatCount),
+ };
+ }
+
+ try {
+ const resp = await createVendor(body);
+ onCreated(resp.id);
+ } catch (err) {
+ if (err instanceof ApiError) {
+ setServerError(err.message);
+ if (err.code === "discovery-incomplete" && err.details?.missing) {
+ const missing = err.details.missing;
+ if (Array.isArray(missing)) {
+ setMissingUrls(missing.filter((m): m is string => typeof m === "string"));
+ }
+ }
+ } else if (err instanceof Error) {
+ setServerError(err.message);
+ } else {
+ setServerError("Failed to create vendor. Try again.");
+ }
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+ );
+}
+
+interface FieldProps {
+ id: string;
+ label: string;
+ input: React.ReactNode;
+ error?: string;
+ required?: boolean;
+}
+
+function Field({ id, label, input, error, required }: FieldProps): JSX.Element {
+ return (
+
+
+ {input}
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/portfolio/PortfolioFilters.tsx b/apps/web/src/components/portfolio/PortfolioFilters.tsx
new file mode 100644
index 0000000..d9023ec
--- /dev/null
+++ b/apps/web/src/components/portfolio/PortfolioFilters.tsx
@@ -0,0 +1,306 @@
+import { Search, X } from "lucide-react";
+import type { TeamMember } from "../../lib/api.js";
+import type { PortfolioFilterState, PostureFilter, SortKey } from "./types.js";
+import { isFiltersActive } from "./types.js";
+
+interface Props {
+ state: PortfolioFilterState;
+ onChange: (next: PortfolioFilterState) => void;
+ categoryOptions: ReadonlyArray;
+ ownerOptions: ReadonlyArray;
+}
+
+const SORT_OPTIONS: ReadonlyArray<{ value: SortKey; label: string }> = [
+ { value: "default", label: "Default" },
+ { value: "spend-desc", label: "Spend (high → low)" },
+ { value: "renew-soon", label: "Renewing soonest" },
+ { value: "tier", label: "Tier" },
+ { value: "posture", label: "Posture" },
+ { value: "name", label: "Name" },
+];
+
+const TIERS: ReadonlyArray<1 | 2 | 3> = [1, 2, 3];
+const POSTURES: ReadonlyArray = ["ok", "watch", "risk"];
+
+function toggleInArray(arr: ReadonlyArray, v: T): T[] {
+ return arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v];
+}
+
+export function PortfolioFilters({
+ state,
+ onChange,
+ categoryOptions,
+ ownerOptions,
+}: Props): JSX.Element {
+ const active = isFiltersActive(state);
+
+ return (
+
+ {/* Search */}
+
+
+ onChange({ ...state, q: e.target.value })}
+ placeholder="Search vendors..."
+ aria-label="Search vendors"
+ style={{
+ width: "100%",
+ padding: "8px 12px 8px 30px",
+ fontSize: 13,
+ border: "1px solid rgba(15,23,42,0.12)",
+ borderRadius: 8,
+ color: "#0f172a",
+ outline: "none",
+ }}
+ />
+
+
+
+ {TIERS.map((t) => (
+ onChange({ ...state, tiers: toggleInArray(state.tiers, t) })}
+ label={`T${t}`}
+ />
+ ))}
+
+
+
+ {POSTURES.map((p) => (
+ onChange({ ...state, postures: toggleInArray(state.postures, p) })}
+ label={p === "ok" ? "OK" : p === "watch" ? "Watch" : "Risk"}
+ />
+ ))}
+
+
+
({ value: c, label: c }))}
+ selected={state.categories}
+ onChange={(next) => onChange({ ...state, categories: next })}
+ />
+
+
+
+
+
+ {active && (
+
+ )}
+
+ );
+}
+
+const baseSelectStyle: React.CSSProperties = {
+ padding: "8px 10px",
+ fontSize: 13,
+ border: "1px solid rgba(15,23,42,0.12)",
+ borderRadius: 8,
+ background: "#ffffff",
+ color: "#0f172a",
+ cursor: "pointer",
+ textTransform: "capitalize",
+};
+
+function ChipGroup({ label, children }: { label: string; children: React.ReactNode }): JSX.Element {
+ return (
+
+
+ {label}
+
+ {children}
+
+ );
+}
+
+function Chip({ active, onClick, label }: { active: boolean; onClick: () => void; label: string }): JSX.Element {
+ return (
+
+ );
+}
+
+interface SelectMultiProps {
+ label: string;
+ options: ReadonlyArray<{ value: string; label: string }>;
+ selected: ReadonlyArray;
+ onChange: (next: string[]) => void;
+}
+
+function SelectMulti({ label, options, selected, onChange }: SelectMultiProps): JSX.Element {
+ // Single-row pseudo-multi: