import { basename, dirname } from 'node:path'; import type { LooseDeliverable, MonitorProject, WorkspaceModel } from './types.js'; import { emitLiveRefresh, liveRefreshLabel } from './live-page.js'; const esc = (s: unknown): string => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c] as string)); /* ── THUMBNAIL URL ── * The server serves project images from /m//. * Returns null when the project has no representative image (→ monogram fallback). */ function thumbUrl(p: MonitorProject): string | null { if (!p.thumbRelPath) return null; const rel = p.thumbRelPath.split('/').map(encodeURIComponent).join('/'); return `/m/${encodeURIComponent(p.workspaceRoot)}/${rel}`; } /** Build a `/p//` drill-in URL for one surface (path-segments encoded). */ function surfaceHref(workspaceRoot: string, relPath: string): string { return `/p/${encodeURIComponent(workspaceRoot)}/${relPath.split('/').map(encodeURIComponent).join('/')}`; } /** Whole-card drill-in: the project's detail gallery. Always available, so EVERY * card is clickable — even projects with no preview/review.html surface. */ function cardHref(p: MonitorProject): string { return `/project/${encodeURIComponent(p.workspaceRoot)}/${encodeURIComponent(p.slug)}`; } function initial(p: MonitorProject): string { return esc((p.displayName || p.slug || '?').trim().charAt(0).toUpperCase() || '?'); } function providerPill(p: MonitorProject): string { return p.provider ? `${esc(p.provider)}` : ''; } function dateMeta(p: MonitorProject): string { return p.lastActivity ? esc(p.lastActivity.slice(0, 10)) : ''; } /* A monogram placeholder block (warm gradient + initial) used when no image exists. */ function monogram(p: MonitorProject, cls: string): string { return `
${initial(p)}
`; } const CSS = ` :root{ /* — asserted tokens (values fixed by tests) — */ --mc-bg:#EAE4D6; --mc-accent:#C2410C; /* — warm paper surfaces — */ --mc-surface:#F6F1E6; --mc-surface-2:#FCF8F0; --mc-border:#DBD2BE; --mc-border-soft:#E8E0CF; --mc-ink:#221E18; --mc-ink-2:#6B6253; --mc-ink-3:#9C9382; /* — cinema accents — */ --mc-accent-2:#E0691E; --mc-accent-soft:#F4E2D0; --mc-charcoal:#272019; --st-rendering:#C2410C; --st-needs:#A15C12; --st-blocked:#A8231E; --st-review:#6E6220; --st-delivered:#3F6B28; --st-idle:#9C9382; --st-rendering-soft:#F6E0CF; --st-needs-soft:#F0E5CF; --st-blocked-soft:#F2D8D6; --st-review-soft:#EBE8CE; --st-delivered-soft:#E5EDD7; --st-idle-soft:#E9E2D4; /* — type — */ --font-display:"Fraunces",Georgia,"Times New Roman",serif; --font-body:"Hanken Grotesk",system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,sans-serif; --font-mono:"JetBrains Mono",ui-monospace,Menlo,"Courier New",monospace; /* — shape & depth — */ --r-card:16px; --r-thumb:11px; --shadow:0 1px 1px rgba(40,30,20,.04),0 10px 26px -18px rgba(64,42,22,.34); --shadow-h:0 2px 4px rgba(40,30,20,.06),0 20px 44px -20px rgba(96,50,16,.44); --leader:repeating-linear-gradient(90deg,var(--mc-accent) 0,var(--mc-accent) 13px,var(--mc-accent-2) 13px,var(--mc-accent-2) 26px); } *,*::before,*::after{box-sizing:border-box} body{ margin:0;color:var(--mc-ink);min-height:100vh; font:14px/1.5 var(--font-body); font-feature-settings:"ss01","cv01"; -webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility; background: radial-gradient(1100px 520px at 88% -8%, rgba(224,105,30,.08), transparent 58%), radial-gradient(840px 520px at -8% 108%, rgba(63,107,40,.06), transparent 52%), var(--mc-bg); background-attachment:fixed; } /* filmic grain — fixed, multiply, barely-there */ body::before{ content:"";position:fixed;inset:0;z-index:0;pointer-events:none;opacity:.05;mix-blend-mode:multiply; background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.82' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); } /* film-leader rail across the very top */ body::after{content:"";position:fixed;top:0;left:0;right:0;height:3px;z-index:60;background:var(--leader);opacity:.92;pointer-events:none} a{color:inherit;text-decoration:none} .wrap{position:relative;z-index:1;max-width:1200px;margin:0 auto;padding:34px 32px 32px} @media(max-width:900px){.wrap{padding:28px 22px}} /* ── HEADER ── */ .mc-header{ position:sticky;top:0;z-index:20; background:linear-gradient(180deg,rgba(252,248,240,.96),rgba(246,241,230,.92)); backdrop-filter:saturate(1.1) blur(8px); border-bottom:1px solid var(--mc-border); margin:-34px -32px 0;padding:18px 32px 16px; display:flex;align-items:center;gap:18px;flex-wrap:wrap; } @media(max-width:900px){.mc-header{margin:-28px -22px 0;padding:16px 22px}} .mc-brand{display:flex;align-items:center;gap:13px;flex:0 0 auto} .mc-glyph{ position:relative;width:34px;height:34px;border-radius:9px;flex-shrink:0; background:radial-gradient(circle at 32% 28%,var(--mc-accent-2),var(--mc-accent)); color:#fff;display:flex;align-items:center;justify-content:center;font-size:17px; box-shadow:0 2px 5px rgba(140,52,10,.32),inset 0 1px 0 rgba(255,255,255,.28); } /* sprocket notches flanking the glyph */ .mc-glyph::before,.mc-glyph::after{ content:"";position:absolute;top:50%;transform:translateY(-50%); width:3px;height:14px;border-radius:2px; background:repeating-linear-gradient(var(--mc-border) 0,var(--mc-border) 2px,transparent 2px,transparent 5px); } .mc-glyph::before{left:-8px}.mc-glyph::after{right:-8px} .mc-wordmark{line-height:1.1;padding-left:6px} .mc-wordmark h1{margin:0;font-family:var(--font-display);font-optical-sizing:auto;font-size:23px;font-weight:600;letter-spacing:-.3px;color:var(--mc-ink)} .mc-wordmark .mc-sub{margin-top:1px;font-family:var(--font-mono);font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.18em;color:var(--mc-ink-3)} /* ── STAT STRIP ── */ .mc-stats{display:flex;align-items:center;gap:5px;flex-wrap:wrap;margin-left:auto} .mc-stat{ display:inline-flex;align-items:center;gap:6px;cursor:pointer; font-family:var(--font-mono);font-size:11px;font-weight:600;color:var(--mc-ink-2); background:none;border:1px solid transparent;border-radius:999px; padding:5px 11px;transition:background .14s,border-color .14s,color .14s; } .mc-stat:hover{background:var(--mc-surface-2);border-color:var(--mc-border);color:var(--mc-ink)} .mc-stat .mc-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0;background:var(--mc-ink-3);box-shadow:0 0 0 3px rgba(0,0,0,.03)} .mc-stat .mc-n{font-weight:700;color:var(--mc-ink);font-variant-numeric:tabular-nums} .mc-stat.is-zero{color:var(--mc-ink-3);opacity:.7} .mc-stat.is-zero .mc-n{color:var(--mc-ink-3)} .mc-stat.is-zero .mc-dot{background:var(--mc-ink-3)!important;box-shadow:none} .mc-stat.s-rendering .mc-dot{background:var(--st-rendering);box-shadow:0 0 0 3px var(--st-rendering-soft)} .mc-stat.s-needs .mc-dot{background:var(--st-needs);box-shadow:0 0 0 3px var(--st-needs-soft)} .mc-stat.s-review .mc-dot{background:var(--st-review);box-shadow:0 0 0 3px var(--st-review-soft)} .mc-stat.s-delivered .mc-dot{background:var(--st-delivered);box-shadow:0 0 0 3px var(--st-delivered-soft)} .mc-stat-sep{width:1px;height:15px;background:var(--mc-border);margin:0 5px} .mc-stat-total{font-family:var(--font-mono);font-size:11px;font-weight:700;color:var(--mc-ink-2);padding:4px 4px;font-variant-numeric:tabular-nums} .mc-head-right{display:flex;align-items:center;gap:14px;margin-left:14px} @media(max-width:900px){.mc-stats{margin-left:0;width:100%;order:3}.mc-head-right{margin-left:auto}} .mc-updated{font-family:var(--font-mono);font-size:10.5px;color:var(--mc-ink-3);white-space:nowrap;letter-spacing:.01em} .mc-new{ display:inline-flex;align-items:center;gap:5px; background:linear-gradient(180deg,var(--mc-accent-2),var(--mc-accent)); color:#fff;font-weight:600;font-size:12.5px; padding:8px 14px;border-radius:9px;border:none;cursor:pointer;white-space:nowrap; box-shadow:0 2px 5px rgba(150,56,10,.28),inset 0 1px 0 rgba(255,255,255,.25); transition:transform .12s,box-shadow .12s,filter .12s; } .mc-new:hover{filter:brightness(1.06);transform:translateY(-1px);box-shadow:0 5px 14px -4px rgba(150,56,10,.5),inset 0 1px 0 rgba(255,255,255,.25)} .mc-scratch{ font-family:var(--font-mono);font-size:10.5px;font-weight:600;color:var(--mc-ink-2); padding:5px 11px;border:1px solid var(--mc-border);border-radius:999px;background:var(--mc-surface-2);transition:background .14s} .mc-scratch:hover{background:var(--mc-bg);border-color:var(--mc-ink-3)} /* ── TIERS ── */ .mc-tiers{margin-top:30px} .mc-tier{margin-bottom:34px} .mc-tier:last-child{margin-bottom:0} .mc-tier-hd{display:flex;align-items:center;gap:11px;margin-bottom:16px} .mc-tier-bar{width:4px;height:15px;border-radius:3px;flex-shrink:0;box-shadow:0 0 8px -1px currentColor} .mc-tier-label{font-family:var(--font-mono);font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.2em;color:var(--mc-ink)} .mc-tier-n{font-family:var(--font-mono);font-size:11px;font-weight:600;color:var(--mc-ink-3);font-variant-numeric:tabular-nums} .mc-tier-hd::after{content:"";flex:1;height:1px;background:linear-gradient(90deg,var(--mc-border),transparent)} /* ── page-load reveal (one orchestrated stagger) ── */ @keyframes mc-rise{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}} @media(prefers-reduced-motion:no-preference){ .mc-tier,.mc-calm{animation:mc-rise .55s cubic-bezier(.22,.7,.2,1) both} .mc-tier:nth-of-type(1){animation-delay:.04s} .mc-tier:nth-of-type(2){animation-delay:.1s} .mc-tier:nth-of-type(3){animation-delay:.16s} .mc-tier:nth-of-type(4){animation-delay:.22s} .mc-tier:nth-of-type(5){animation-delay:.28s} } /* ── REASSURANCE BANNER ── */ .mc-calm{ background:var(--mc-surface);border:1px solid var(--mc-border);border-radius:var(--r-card); padding:14px 18px;text-align:center;color:var(--mc-ink-2);font-size:13px; box-shadow:var(--shadow);margin-bottom:28px; } /* ── EMPTY WORKSPACE ── */ .mc-empty-ws{ background:var(--mc-surface);border:1px solid var(--mc-border);border-radius:var(--r-card); padding:56px 24px;text-align:center;box-shadow:var(--shadow);margin-top:28px; } .mc-empty-ws .mc-glyph{width:48px;height:48px;border-radius:12px;font-size:26px;margin:0 auto 14px} .mc-empty-ws h2{margin:0 0 16px;font-size:17px;font-weight:650;color:var(--mc-ink)} /* ── MONOGRAM FALLBACK ── */ .mc-mono{ display:flex;align-items:center;justify-content:center; background:linear-gradient(135deg,var(--mc-accent-soft),var(--mc-surface-2)); color:var(--st-idle);font-weight:700; } .mc-mono span{font-size:1.6em;opacity:.85} /* ── PROVIDER PILL ── */ .mc-prov{ font:10px/1 ui-monospace,Menlo,"Courier New",monospace;font-weight:600; letter-spacing:.02em;color:var(--mc-ink-2);background:rgba(43,38,34,.06); border:1px solid var(--mc-border);padding:2px 7px;border-radius:4px;white-space:nowrap; } /* ════════ TIER 1 — NOW RENDERING (hero tiles) ════════ */ .mc-hero-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px} @media(max-width:1023px){.mc-hero-grid{grid-template-columns:1fr}} .mc-hero{ display:block;background:var(--mc-surface);border:1px solid var(--mc-border); border-radius:var(--r-card);overflow:hidden;box-shadow:var(--shadow); transition:box-shadow .15s,transform .15s; } .mc-hero:hover{box-shadow:var(--shadow-h);transform:translateY(-2px)} .mc-hero-thumb{position:relative;aspect-ratio:16/9;width:100%;overflow:hidden;background:var(--mc-surface-2)} .mc-hero-thumb img{width:100%;height:100%;object-fit:cover;display:block} .mc-hero-thumb .mc-mono{position:absolute;inset:0} .mc-hero-grad{position:absolute;inset:0;background:linear-gradient(to top,rgba(0,0,0,.42),transparent 42%);pointer-events:none} .mc-ov{ position:absolute;display:inline-flex;align-items:center;gap:5px; background:rgba(0,0,0,.45);color:#fff;font-size:10px;font-weight:700; text-transform:uppercase;letter-spacing:.06em;padding:4px 9px;border-radius:999px; backdrop-filter:blur(2px); } .mc-ov-tl{top:10px;left:10px}.mc-ov-tr{top:10px;right:10px;font-variant-numeric:tabular-nums} .mc-ov .mc-dot{width:7px;height:7px;border-radius:50%;background:var(--st-rendering)} .mc-ov-prov{font:10px/1 ui-monospace,Menlo,monospace;font-weight:600;text-transform:none;letter-spacing:0} .mc-livebar{position:absolute;left:0;right:0;bottom:0;height:4px;background:rgba(0,0,0,.25);overflow:hidden} .mc-livebar-fill{height:100%;background:var(--st-rendering);transition:width .4s} .mc-livebar-shim{ position:absolute;inset:0;width:40%; background:linear-gradient(90deg,transparent,var(--st-rendering),transparent); animation:mc-shim 1.4s linear infinite; } @keyframes mc-shim{0%{transform:translateX(-100%)}100%{transform:translateX(350%)}} .mc-hero-foot{padding:13px 14px} .mc-hero-foot-top{display:flex;align-items:baseline;justify-content:space-between;gap:10px} .mc-hero-name{font-family:var(--font-display);font-optical-sizing:auto;font-size:17px;font-weight:560;letter-spacing:-.1px;color:var(--mc-ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .mc-hero-eta{font-family:var(--font-mono);font-size:10.5px;color:var(--mc-ink-2);white-space:nowrap;flex-shrink:0} .mc-strip{display:flex;gap:4px;margin-top:9px} .mc-strip-cell{width:40px;height:24px;border-radius:4px;flex-shrink:0;background:var(--mc-surface-2);border:1px solid var(--mc-border-soft);overflow:hidden;position:relative} .mc-strip-cell.lit{border-color:var(--st-rendering)} .mc-strip-cell.lit img{width:100%;height:100%;object-fit:cover} .mc-strip-cell.lit.solid{background:var(--st-rendering)} /* ════════ TIER 2 — NEEDS YOU (action cards) ════════ */ .mc-needs-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px} @media(max-width:1023px){.mc-needs-grid{grid-template-columns:1fr}} .mc-need{ display:flex;gap:14px;background:var(--mc-surface);border:1px solid var(--mc-border); border-radius:var(--r-card);padding:14px;box-shadow:var(--shadow); transition:box-shadow .15s,transform .15s; } .mc-need:hover{box-shadow:var(--shadow-h);transform:translateY(-2px)} .mc-need-thumb{width:96px;height:96px;border-radius:var(--r-thumb);overflow:hidden;flex-shrink:0;background:var(--mc-surface-2)} .mc-need-thumb img{width:100%;height:100%;object-fit:cover;display:block} .mc-need-thumb .mc-mono{width:100%;height:100%} .mc-need-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:7px} .mc-need-r1{display:flex;align-items:center;gap:8px} .mc-need-name{font-family:var(--font-display);font-optical-sizing:auto;font-size:18px;font-weight:560;letter-spacing:-.1px;color:var(--mc-ink);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .mc-chip{ flex-shrink:0;display:inline-flex;align-items:center;gap:4px; font-family:var(--font-mono);font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.1em; padding:4px 9px;border-radius:999px; } .mc-chip.c-blocked{background:var(--st-blocked-soft);color:var(--st-blocked)} .mc-chip.c-review{background:var(--st-review-soft);color:var(--st-review)} .mc-chip.c-needs{background:var(--st-needs-soft);color:var(--st-needs)} .mc-need-meta{display:flex;align-items:center;gap:7px;flex-wrap:wrap;font-family:var(--font-mono);font-size:10.5px;color:var(--mc-ink-2);font-variant-numeric:tabular-nums} .mc-reason{ background:var(--st-blocked-soft);border-radius:6px; border-left:3px solid var(--st-blocked);padding:7px 10px; font-size:11.5px;line-height:1.4;color:var(--mc-ink);word-break:break-word; overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;max-height:2.9em; } .mc-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:auto} .mc-btn{ display:inline-flex;align-items:center;height:29px;padding:0 13px;border-radius:8px; font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap; border:1px solid var(--mc-border);background:var(--mc-surface-2);color:var(--mc-ink); transition:background .12s,border-color .12s,transform .12s; } .mc-btn:hover{background:var(--mc-bg);border-color:var(--mc-ink-3)} .mc-btn.primary{background:linear-gradient(180deg,var(--mc-accent-2),var(--mc-accent));color:#fff;border-color:transparent;box-shadow:0 1px 3px rgba(150,56,10,.3),inset 0 1px 0 rgba(255,255,255,.22)} .mc-btn.primary:hover{filter:brightness(1.06);transform:translateY(-1px)} /* ── PROGRESS METER (shared) ── */ .mc-meter{display:flex;align-items:center;gap:9px} .mc-meter-track{flex:1;height:5px;border-radius:999px;background:var(--mc-border-soft);overflow:hidden;display:flex;box-shadow:inset 0 1px 1px rgba(40,30,20,.08)} .mc-meter-fill{height:100%;border-radius:999px} .mc-meter-q{height:100%;background:repeating-linear-gradient(45deg,var(--mc-border),var(--mc-border) 3px,transparent 3px,transparent 6px)} .mc-meter-lbl{font-family:var(--font-mono);font-size:10.5px;font-weight:700;color:var(--mc-ink-2);white-space:nowrap;font-variant-numeric:tabular-nums} /* ════════ TIER 3 — DELIVERED (compact tiles) ════════ */ .mc-del-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px} @media(max-width:1023px){.mc-del-grid{grid-template-columns:repeat(auto-fill,minmax(140px,1fr))}} .mc-del{ display:block;background:var(--mc-surface);border:1px solid var(--mc-border); border-radius:var(--r-thumb);overflow:hidden;box-shadow:var(--shadow); transition:box-shadow .15s,transform .15s; } .mc-del:hover{box-shadow:var(--shadow-h);transform:translateY(-2px)} .mc-del-thumb{position:relative;aspect-ratio:16/9;background:var(--mc-surface-2);overflow:hidden} .mc-del-thumb img{width:100%;height:100%;object-fit:cover;display:block} .mc-del-thumb .mc-mono{position:absolute;inset:0} .mc-del .mc-ov{top:8px;left:8px;font-size:9px;padding:3px 8px} .mc-del .mc-ov .mc-dot{background:var(--st-delivered)} .mc-del-hover{ position:absolute;left:0;right:0;bottom:0;display:flex;gap:6px;padding:8px; background:linear-gradient(to top,rgba(0,0,0,.55),transparent); opacity:0;transition:opacity .15s; } .mc-del:hover .mc-del-hover{opacity:1} .mc-del-hover .mc-btn{height:24px;padding:0 9px;font-size:11px;background:rgba(255,255,255,.92);border-color:transparent;color:var(--mc-ink)} .mc-del-foot{padding:9px 11px} .mc-del-name{font-family:var(--font-display);font-optical-sizing:auto;font-size:14.5px;font-weight:560;letter-spacing:-.1px;color:var(--mc-ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .mc-del-meta{font-family:var(--font-mono);font-size:9.5px;color:var(--mc-ink-2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-top:3px;font-variant-numeric:tabular-nums} /* ════════ TIER 4 — IDLE (dense rows) ════════ */ .mc-idle{background:var(--mc-surface);border:1px solid var(--mc-border);border-radius:var(--r-card);overflow:hidden;box-shadow:var(--shadow)} .mc-idle-row{ display:flex;align-items:center;gap:11px;height:44px;padding:0 14px; border-top:1px solid var(--mc-border-soft);transition:background .12s;color:inherit; } .mc-idle-row:first-child{border-top:none} .mc-idle-row:hover{background:var(--mc-surface-2)} .mc-idle-mono{ width:29px;height:29px;border-radius:8px;flex-shrink:0;font-size:12px; display:flex;align-items:center;justify-content:center; background:var(--st-idle-soft);color:var(--mc-ink-2);font-weight:700;font-family:var(--font-display)} .mc-idle-name{font-family:var(--font-display);font-optical-sizing:auto;font-size:15.5px;font-weight:500;letter-spacing:-.1px;color:var(--mc-ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .mc-idle-tag{font-family:var(--font-mono);font-size:9.5px;color:var(--mc-ink-3);white-space:nowrap;flex-shrink:0;font-variant-numeric:tabular-nums} .mc-idle-r{margin-left:auto;display:flex;align-items:center;gap:8px;flex-shrink:0} .mc-idle-start{font-family:var(--font-mono);font-size:11px;font-weight:600;color:var(--mc-accent);background:none;border:none;cursor:pointer;padding:4px 6px;letter-spacing:.02em} .mc-idle-start:hover{text-decoration:underline} .mc-idle-more{font-size:12px;color:var(--mc-ink-3);background:none;border:none;cursor:pointer;padding:4px 8px} details.mc-idle-disc>summary{ list-style:none;cursor:pointer;font-size:12.5px;font-weight:600;color:var(--mc-ink-2); padding:11px 14px;border-top:1px solid var(--mc-border-soft); } details.mc-idle-disc>summary::-webkit-details-marker{display:none} details.mc-idle-disc>summary:hover{background:var(--mc-surface-2)} details.mc-idle-disc[open]>summary{color:var(--mc-ink)} /* ── STRETCHED CARD LINK ── * Cards that contain action-button anchors must NOT be wrapped in an outer * (nested anchors are invalid and split in the DOM). Instead the card is a * positioned container with a full-bleed transparent link for the dashboard nav, * and the action buttons sit above it (z-index) so their own hrefs win. */ .mc-stretch{position:relative} .mc-stretch-link{position:absolute;inset:0;z-index:1} .mc-stretch .mc-actions,.mc-stretch .mc-del-hover{position:relative;z-index:2} /* ════════ LOOSE DELIVERABLES (rendered videos outside any workspace) ════════ */ .mc-loose-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px} @media(max-width:1023px){.mc-loose-grid{grid-template-columns:repeat(auto-fill,minmax(150px,1fr))}} .mc-loose{ display:block;background:var(--mc-surface);border:1px solid var(--mc-border); border-radius:var(--r-thumb);overflow:hidden;box-shadow:var(--shadow); transition:box-shadow .15s,transform .15s; } .mc-loose:hover{box-shadow:var(--shadow-h);transform:translateY(-2px)} .mc-loose-thumb{ position:relative;aspect-ratio:16/9;overflow:hidden; background:linear-gradient(135deg,var(--mc-accent-soft),var(--mc-surface-2)); display:flex;align-items:center;justify-content:center; } .mc-loose-poster{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block} /* On a real poster the badge needs a darker scrim to read over varied frames. */ .mc-loose-thumb.has-poster::after{content:"";position:absolute;inset:0;background:linear-gradient(to top,rgba(0,0,0,.28),transparent 55%);pointer-events:none} .mc-loose-play{ position:relative;z-index:1; width:38px;height:38px;border-radius:50%;flex-shrink:0; background:rgba(43,38,34,.55);color:#fff; display:flex;align-items:center;justify-content:center;font-size:15px; } .mc-loose-thumb.has-poster .mc-loose-play{background:rgba(0,0,0,.5);backdrop-filter:blur(2px)} .mc-loose:hover .mc-loose-play{background:var(--mc-accent)} .mc-loose-foot{padding:9px 11px} .mc-loose-name{font-family:var(--font-display);font-optical-sizing:auto;font-size:14px;font-weight:540;letter-spacing:-.1px;color:var(--mc-ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .mc-loose-meta{font-family:var(--font-mono);font-size:9.5px;color:var(--mc-ink-2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-top:3px;font-variant-numeric:tabular-nums} .mc-loose-note{font-family:var(--font-mono);font-size:10px;color:var(--mc-ink-3);margin:-8px 0 14px} /* ── REASSURANCE BANNER (Fraunces voice) ── */ .mc-calm{font-family:var(--font-display);font-size:15px;font-weight:440;letter-spacing:.1px} /* ── FOOTER ── */ .mc-footer{ margin-top:40px;padding-top:18px;border-top:1px solid var(--mc-border); display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:6px; } .mc-footer span{font-family:var(--font-mono);font-size:10px;color:var(--mc-ink-3);letter-spacing:.02em} `.trim(); const SURFACE_LABEL: Record = { dashboard: 'Dashboard', preview: 'Preview', review: 'Review', run: 'Run', }; /* ── pick the run.html / review.html surface URL by kind for action buttons ── */ function surfaceByKind(p: MonitorProject, kind: string): string | null { const s = p.surfaces.find((x) => x.kind === kind); return s ? surfaceHref(p.workspaceRoot, s.relPath) : null; } /* ════════ TIER 1 — NOW RENDERING ════════ */ function heroTile(p: MonitorProject): string { const c = p.run.counts; const href = cardHref(p); const url = thumbUrl(p); const known = c.total > 0; const pct = known ? Math.round((c.done / c.total) * 100) : 0; const liveFill = known ? `
` : `
`; const thumbInner = url ? `` : monogram(p, ''); const sceneProg = known ? `${c.done} / ${c.total} scenes` : 'rendering'; const eta = known ? `rendering ${Math.min(c.done + 1, c.total)} of ${c.total}` : 'rendering…'; // Filmstrip: up to 5 cells, done lit, pending hairline. const stripN = known ? Math.min(c.total, 5) : 0; const cells: string[] = []; for (let i = 0; i < stripN; i++) { if (i < c.done) { cells.push(url ? `
` : `
`); } else { cells.push(`
`); } } const strip = cells.length ? `
${cells.join('')}
` : ''; const provOv = p.provider ? `${esc(p.provider)}` : ''; const inner = `
${thumbInner}
Rendering ${provOv}
${esc(sceneProg)}
${liveFill}
${esc(p.displayName)} ${esc(eta)}
${strip}
`; return href ? `
${inner}` : `
${inner}
`; } /* ════════ TIER 2 — NEEDS YOU ════════ */ type NeedKind = 'blocked' | 'review' | 'approval'; function needKind(p: MonitorProject): NeedKind { if (p.run.headline === 'blocked') return 'blocked'; // awaiting-review headline: distinguish approval-gate from generic review by reviewState. if (p.run.reviewState === 'stale' || p.run.reviewState === 'missing') return 'approval'; return 'review'; } const NEED_CHIP: Record = { blocked: `⛔ Blocked`, review: `◴ Awaiting review`, approval: `◷ Needs approval`, }; function needActions(p: MonitorProject, kind: NeedKind): string { const open = cardHref(p); const review = surfaceByKind(p, 'review') ?? open; const preview = surfaceByKind(p, 'preview') ?? open; const btn = (label: string, href: string | null, primary = false): string => { const cls = `mc-btn${primary ? ' primary' : ''}`; return href ? `${esc(label)}` : `${esc(label)}`; }; if (kind === 'blocked') { return btn('Open project', open, true); } if (kind === 'approval') { return `${btn('Open review portal', review, true)}${btn('Preview', preview)}`; } return `${btn('Review storyboard', review, true)}${btn('Approve & deliver', review)}${btn('Preview', preview)}`; } function needCard(p: MonitorProject): string { const c = p.run.counts; const href = cardHref(p); const url = thumbUrl(p); const kind = needKind(p); const thumb = `
${url ? `` : monogram(p, '')}
`; const metaBits = [`${c.done} / ${c.total} done`]; if (c.queued) metaBits.push(`${c.queued} queued`); const dt = dateMeta(p); const metaLine = `
${esc(metaBits.join(' · '))} ${providerPill(p)} ${dt ? `${dt}` : ''}
`; // Progress meter only when total>0. const pct = c.total ? Math.round((c.done / c.total) * 100) : 0; const meter = c.total > 0 ? `
${c.done} / ${c.total}
` : ''; // Reason callout — blocked only. const reason = kind === 'blocked' && p.run.blockers.length ? `
${esc(p.run.blockers[0].split(' (')[0].trim().slice(0, 160))}
` : ''; const body = `
${esc(p.displayName)} ${NEED_CHIP[kind]}
${metaLine} ${meter} ${reason}
${needActions(p, kind)}
`; // The card body links to the dashboard via a stretched overlay link (NOT an // outer , which would nest invalidly with the action-button anchors). const stretch = href ? `` : ''; return `
${stretch}${thumb}${body}
`; } /* ════════ TIER 3 — DELIVERED ════════ */ function deliveredTile(p: MonitorProject): string { const c = p.run.counts; const href = cardHref(p); const url = thumbUrl(p); const preview = surfaceByKind(p, 'preview'); const thumb = url ? `` : monogram(p, ''); const metaBits = [`${c.done} / ${c.total}`]; if (p.provider) metaBits.push(esc(p.provider)); const dt = dateMeta(p); if (dt) metaBits.push(dt); const hoverRow = `
${preview ? `Preview` : ''}
`; const inner = `
${thumb} Delivered ${hoverRow}
${esc(p.displayName)}
${metaBits.join(' · ')}
`; // Stretched overlay link for the card; the hover [Preview] button is its own anchor above it. const stretch = href ? `` : ''; return `
${stretch}${inner}
`; } /* ════════ TIER 4 — IDLE ════════ */ function idleRow(p: MonitorProject): string { const href = cardHref(p); const c = p.run.counts; const inner = `${initial(p)} ${esc(p.displayName)} Idle ${c.done}/${c.total} Start render `; return href ? `${inner}` : `
${inner}
`; } /* ── TIER HEADER ── */ const TIER_BAR: Record = { rendering: 'var(--st-rendering)', needs: 'var(--st-needs)', delivered: 'var(--st-delivered)', idle: 'var(--st-idle)', }; function tierHeader(color: string, label: string, n: number): string { return `
${esc(label)} ${n}
`; } /* ════════ LOOSE DELIVERABLES ════════ */ function looseCard(d: LooseDeliverable): string { // Served via the /m/ media route using the exact loose dir + filename. const href = `/m/${encodeURIComponent(d.dir)}/${encodeURIComponent(d.name)}`; const folder = d.dir.slice(d.dir.lastIndexOf('/') + 1); const sizeMb = (d.sizeBytes / (1024 * 1024)).toFixed(1); const date = new Date(d.mtimeMs).toISOString().slice(0, 10); const meta = `${esc(folder)} · ${esc(sizeMb)} MB · ${esc(date)}`; // Cached poster frame (served via /m/) when extraction succeeded; else the // gradient placeholder. The ▶ play badge sits on top of either backdrop. const poster = d.thumbPath ? `` : ''; const thumbCls = d.thumbPath ? 'mc-loose-thumb has-poster' : 'mc-loose-thumb'; return `
${poster}
${esc(d.name)}
${meta}
`; } export function renderMissionControlHtml(model: WorkspaceModel): string { const { counts } = model; const scanTime = esc(model.generatedAt.slice(11, 19)); // ── Single-tier membership (precedence: rendering → needs → delivered → idle) ── const rendering = model.sections.rendering; const renderingSet = new Set(rendering.map((p) => `${p.workspaceRoot}::${p.slug}`)); const needsAll = model.sections['needs-you'].filter( (p) => !renderingSet.has(`${p.workspaceRoot}::${p.slug}`), ); // Sort: blocked → review → approval (blocked first). const NEED_ORDER: Record = { blocked: 0, review: 1, approval: 2 }; const needs = [...needsAll].sort((a, b) => NEED_ORDER[needKind(a)] - NEED_ORDER[needKind(b)]); const needsSet = new Set(needs.map((p) => `${p.workspaceRoot}::${p.slug}`)); const delivered = model.sections.delivered.filter( (p) => !renderingSet.has(`${p.workspaceRoot}::${p.slug}`) && !needsSet.has(`${p.workspaceRoot}::${p.slug}`), ); const deliveredSet = new Set(delivered.map((p) => `${p.workspaceRoot}::${p.slug}`)); // Idle = everything in `all` not already placed. const idle = model.sections.all.filter((p) => { const k = `${p.workspaceRoot}::${p.slug}`; return !renderingSet.has(k) && !needsSet.has(k) && !deliveredSet.has(k); }); // Most-progressed-first within rendering. const renderingSorted = [...rendering].sort((a, b) => { const pa = a.run.counts.total ? a.run.counts.done / a.run.counts.total : 0; const pb = b.run.counts.total ? b.run.counts.done / b.run.counts.total : 0; return pb - pa; }); // ── stat strip ── const stat = (cls: string, n: number, label: string, target: string): string => { const zero = n === 0 ? ' is-zero' : ''; return `${n} ${esc(label)}`; }; const reviewCount = needs.filter((p) => needKind(p) !== 'blocked').length; const statStrip = `
${stat('s-rendering', counts.rendering, 'rendering', 'tier-rendering')} ${stat('s-needs', counts.needsYou, 'needs you', 'tier-needs')} ${stat('s-review', reviewCount, 'review', 'tier-needs')} ${stat('s-delivered', counts.delivered, 'delivered', 'tier-delivered')} ${counts.total} total
`; const scratchHtml = counts.scratchHidden ? `show all (+${counts.scratchHidden} scratch)` : ''; // ── tiers ── const tiers: string[] = []; if (renderingSorted.length) { tiers.push(`
${tierHeader(TIER_BAR.rendering, 'Now rendering', renderingSorted.length)}
${renderingSorted.map(heroTile).join('')}
`); } if (needs.length) { tiers.push(`
${tierHeader(TIER_BAR.needs, 'Needs you', needs.length)}
${needs.map(needCard).join('')}
`); } // Both priority tiers empty → slim calm banner (only when there ARE projects). const calmBanner = (!renderingSorted.length && !needs.length && model.sections.all.length) ? `
All caught up — nothing rendering and nothing needs you.
` : ''; if (delivered.length) { tiers.push(`
${tierHeader(TIER_BAR.delivered, 'Delivered', delivered.length)}
${delivered.map(deliveredTile).join('')}
`); } if (idle.length) { const head = tierHeader(TIER_BAR.idle, 'Idle · ready to start', idle.length); let idleBody: string; if (idle.length > 6) { const shown = idle.slice(0, 6).map(idleRow).join(''); const rest = idle.slice(6).map(idleRow).join(''); idleBody = `
${shown}
Show ${idle.length - 6} more idle project${idle.length - 6 !== 1 ? 's' : ''} ▸${rest}
`; } else { idleBody = `
${idle.map(idleRow).join('')}
`; } tiers.push(`
${head}${idleBody}
`); } // ── Loose deliverables — rendered videos outside any projects/ workspace ── const loose = model.looseDeliverables; if (loose.length) { // The list is capped at 60 newest-first; note it when the cap is hit. const capped = loose.length >= 60; const note = capped ? `
Showing the 60 most recent — older loose videos are hidden.
` : ''; tiers.push(`
${tierHeader(TIER_BAR.delivered, 'Loose deliverables', loose.length)} ${note}
${loose.map(looseCard).join('')}
`); } // Empty workspace → single centered empty state (only when there are also no // loose deliverables to show, otherwise the loose tier still renders). const emptyWorkspace = (model.sections.all.length === 0 && !loose.length) ? `
🎬

No projects yet

` : ''; const body = emptyWorkspace ? emptyWorkspace : `${calmBanner}
${tiers.join('')}
`; return ` ${emitLiveRefresh({ isRendering: counts.rendering > 0 })} VideoClaw Mission Control
🎬

Mission Control

VideoClaw · Workspace overview
${statStrip}
Updated ${scanTime} UTC · ${liveRefreshLabel(counts.rendering > 0)} ${scratchHtml}
${body}
VideoClaw Mission Control · on-disk reads · ${model.roots.length} root${model.roots.length !== 1 ? 's' : ''} ${liveRefreshLabel(counts.rendering > 0)}
`; }