/* ProofMark Studio — main app: tile-led, grouped, SmallPDF-ish */
const { useState, useEffect, useMemo, useRef, useCallback } = React;
/* ---------- Display filter ----------
* Server sets t.hidden = true for beta/planned/flag-off tiles when the catalog
* is in live-only mode (PROOFMARK_SHOW_ALL_TILES=false, the default). Every
* render surface goes through __pmVisible() so only tiles that work fully show
* up. Roadmap mode (server env var flipped) leaves t.hidden falsy, so the full
* catalog renders. */
const __pmVisible = (arr) => arr.filter(t => !t.hidden);
/* ---------- Shortcuts cheat-sheet ----------
* `?` opens this modal; it lists every keyboard binding the hub registers
* so users don't have to guess. Kept alongside the palette because they
* share a similar chrome shape. */
const SHORTCUTS = [
{ combo: ['Cmd+K', 'Ctrl+K'], label: 'Open command palette' },
{ combo: ['?'], label: 'Open this cheat-sheet' },
{ combo: ['H'], label: 'Go to Home' },
{ combo: ['G'], label: 'Go to All tools' },
{ combo: ['P'], label: 'Go to Pinned' },
{ combo: ['M'], label: 'Go to Platform' },
{ combo: ['Esc'], label: 'Close dialogs / drawers' },
];
const ShortcutsModal = ({ open, onClose }) => {
useEffect(() => {
if (!open) return;
const h = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', h);
return () => window.removeEventListener('keydown', h);
}, [open, onClose]);
if (!open) return null;
return (
e.stopPropagation()} role="dialog" aria-label="Keyboard shortcuts" style={{
width:'min(540px, 92vw)', borderRadius:16,
background:'var(--bg-elev)', border:'1px solid var(--border-strong)',
boxShadow:'var(--shadow-lg)', overflow:'hidden',
}}>
Keyboard shortcuts
Esc
{SHORTCUTS.map((s, i) => (
{s.combo.map((c, j) => {c} )}
{s.label}
))}
);
};
/* ---------- Command Palette ---------- */
const CommandPalette = ({ open, onClose, onRun }) => {
const [q, setQ] = useState('');
const [idx, setIdx] = useState(0);
const inputRef = useRef(null);
useEffect(() => {
if (open) { setQ(''); setIdx(0); setTimeout(() => inputRef.current?.focus(), 20); }
}, [open]);
const results = useMemo(() => {
const tools = __pmVisible(window.PM_TOOLS);
const term = q.trim().toLowerCase();
if (!term) {
return [
{ group:'Popular', items: tools.filter(t => t.popular).slice(0,6) },
{ group:'Pinned', items: tools.filter(t => t.pin).slice(0,4) },
];
}
const hits = tools.filter(t =>
t.title.toLowerCase().includes(term) ||
t.desc.toLowerCase().includes(term) ||
t.cat.includes(term) ||
t.slug.includes(term)
);
return [{ group:`${hits.length} result${hits.length===1?'':'s'}`, items: hits.slice(0, 14) }];
}, [q]);
const flat = results.flatMap(g => g.items);
useEffect(() => {
if (!open) return;
const h = (e) => {
if (e.key === 'Escape') onClose();
else if (e.key === 'ArrowDown') { e.preventDefault(); setIdx(i => Math.min(i+1, flat.length-1)); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setIdx(i => Math.max(i-1, 0)); }
else if (e.key === 'Enter') { e.preventDefault(); const t = flat[idx]; if (t) { onRun(t); onClose(); } }
};
window.addEventListener('keydown', h);
return () => window.removeEventListener('keydown', h);
}, [open, flat, idx, onClose, onRun]);
if (!open) return null;
let running = -1;
return (
e.stopPropagation()} style={{
width:'min(640px, 92vw)', borderRadius:16,
background:'var(--bg-elev)', border:'1px solid var(--border-strong)',
boxShadow:'var(--shadow-lg)', overflow:'hidden',
}}>
{setQ(e.target.value); setIdx(0);}}
placeholder="Search 50+ tools, workflows…"
style={{ flex:1, border:0, outline:0, background:'transparent', color:'var(--text)', fontSize:15, fontFamily:'inherit' }}/>
Esc
{results.map((g, gi) => (
{g.group}
{g.items.length === 0 &&
No matches.
}
{g.items.map(t => {
running++;
const sel = running === idx;
const myIdx = running;
const grp = window.PM_GROUPS.find(x=>x.id===t.group);
return (
{ onRun(t); onClose(); }} onMouseEnter={() => setIdx(myIdx)} style={{
display:'flex', alignItems:'center', gap:12,
width:'100%', padding:'9px 12px', borderRadius:9,
background: sel ? 'var(--bg-elev-2)' : 'transparent',
border:'1px solid ' + (sel ? 'var(--border)' : 'transparent'),
color:'var(--text)', cursor:'pointer', textAlign:'left',
}}>
);
})}
))}
↑ ↓ navigate
↵ open
Esc close
ProofMark Studio · v0.3.4
);
};
/* ---------- Tool drawer ---------- */
const ToolDrawer = ({ tool, onClose }) => {
if (!tool) return null;
const grp = window.PM_GROUPS.find(g => g.id === tool.group);
const tone = grp?.tone || '#7cb0ff';
return (
e.stopPropagation()} style={{
width:'min(520px, 94vw)', height:'100%',
background:'var(--bg-elev)', borderLeft:'1px solid var(--border-strong)',
boxShadow:'var(--shadow-lg)', display:'flex', flexDirection:'column',
}}>
{grp?.label}
{tool.title}
/tools/{tool.slug}
{tool.desc}
{[
{ label:'Avg. run time', value:'1.2s' },
{ label:'Max file size', value:'512 MB' },
{ label:'Docs processed', value:'14.2k' },
{ label:'Last updated', value:'Apr 16' },
].map(m => (
))}
How it works
{['Drop your files into the workspace','Configure options (preserved between runs)','Download or forward the result'].map((s,i)=>(
{i+1}
{s}
))}
{ window.location.href = '/tool/' + tool.slug; }}
style={{ flex:1, padding:'11px 14px', borderRadius:10, background:tone, color:'#0a0a0b', border:0, fontSize:13, fontWeight:600, cursor:'pointer', display:'flex', alignItems:'center', justifyContent:'center', gap:8 }}>
{tool.status === 'live' ? 'Open tool' : 'Open preview'}
Pin
);
};
/* ---------- Big icon tile (SmallPDF-style) ---------- */
const ToolTile = ({ tool, onOpen }) => {
const grp = window.PM_GROUPS.find(g => g.id === tool.group);
const tone = grp?.tone || '#7cb0ff';
const [hover, setHover] = useState(false);
return (
onOpen(tool)}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
position:'relative',
display:'flex', flexDirection:'column', alignItems:'flex-start',
textAlign:'left', padding:'20px 18px 16px',
borderRadius:16,
background: hover
? `linear-gradient(180deg, color-mix(in oklab, ${tone} 9%, var(--bg-elev)) 0%, var(--bg-elev) 100%)`
: 'var(--bg-elev)',
border:'1px solid ' + (hover ? `color-mix(in oklab, ${tone} 45%, var(--border))` : 'var(--border)'),
color:'var(--text)', cursor:'pointer',
transition:'background .15s, border-color .15s, transform .15s',
transform: hover ? 'translateY(-2px)' : 'none',
minHeight: 168,
}}
>
{tool.popular && (
Popular
)}
{tool.title}
{tool.desc}
);
};
/* ---------- Group section header ---------- */
const GroupHeader = ({ group, count, onViewAll }) => (
{String(count).padStart(2,'0')} tools
{group.id}
{group.label}
{group.desc}
);
/* ---------- Hero: visual-first ---------- */
const HeroPanel = ({ onOpenPalette }) => {
const visible = __pmVisible(window.PM_TOOLS);
const liveCount = visible.filter(t=>t.status==='live').length;
// The flying mini-tile cluster behind the hero copy
const stack = [
{ ic:'merge', tone:'#ff7a45', x: 62, y: 6, r:-8 },
{ ic:'docx', tone:'#7cb0ff', x: 72, y: 44, r:5 },
{ ic:'sig', tone:'#ff6b8a', x: 86, y: 18, r:10 },
{ ic:'aa', tone:'#62e0d9', x: 54, y: 42, r:-4 },
{ ic:'ai', tone:'#f0c674', x: 80, y: 64, r:-6 },
];
return (
{/* Floating tiles cluster */}
{stack.map((s,i) => (
))}
Workspace · online
ProofMark Studio · Working hub
Every PDF tool
you need, in one studio .
Merge, split, convert, sign, compress, and proofread — organized like a real workspace. Private, keyboard-first, built for document craft.
Find a tool ⌘K
);
};
/* ---------- Popular strip ---------- */
const PopularStrip = ({ onOpen }) => {
const pops = __pmVisible(window.PM_TOOLS).filter(t => t.popular);
return (
);
};
/* ---------- Grouped catalog ---------- */
const GroupedCatalog = ({ onOpen, activeGroup, onSetGroup }) => {
const groups = activeGroup === 'all' ? window.PM_GROUPS : window.PM_GROUPS.filter(g => g.id === activeGroup);
return (
{groups.map(g => {
const items = __pmVisible(window.PM_TOOLS).filter(t => t.group === g.id);
if (items.length === 0) return null;
return (
);
})}
);
};
/* ---------- Group chips with colored dots ---------- */
const GroupChips = ({ active, onSelect }) => (
onSelect('all')} style={{
display:'flex', alignItems:'center', gap:8, padding:'7px 12px', borderRadius:999,
background: active === 'all' ? 'var(--text)' : 'var(--bg-elev)',
color: active === 'all' ? 'var(--bg)' : 'var(--text-muted)',
border:'1px solid ' + (active==='all' ? 'var(--text)' : 'var(--border)'),
fontSize:12.5, fontWeight:500, cursor:'pointer',
}}>All {__pmVisible(window.PM_TOOLS).length}
{window.PM_GROUPS.map(g => {
const isActive = active === g.id;
const count = __pmVisible(window.PM_TOOLS).filter(t => t.group === g.id).length;
if (count === 0) return null;
return (
onSelect(g.id)} style={{
display:'flex', alignItems:'center', gap:8, padding:'7px 12px', borderRadius:999,
background: isActive ? `color-mix(in oklab, ${g.tone} 16%, transparent)` : 'var(--bg-elev)',
color: isActive ? g.tone : 'var(--text-muted)',
border:'1px solid ' + (isActive ? `color-mix(in oklab, ${g.tone} 40%, transparent)` : 'var(--border)'),
fontSize:12.5, fontWeight:500, cursor:'pointer',
}}>
{g.label}
{count}
);
})}
);
/* RecentStream + Throughput removed — they were hardcoded mock data
(fake users, fake doc names, fake throughput numbers). Real activity
tracking and metrics ship with Phase 17 (database). */
/* ---------- Platform map (real components only) ---------- */
const PlatformMap = () => {
const spokes = [
{ id:'pdf', title:'ProofMark PDF', desc:'Merge, split, compress, convert, sign, watermark, redact.', status:'live', url:'/go/proofmark-pdf' },
{ id:'text', title:'Text Inspection', desc:'Surface hidden characters, normalize whitespace, review typography.', status:'live', url:'/go/text-inspection' },
{ id:'site', title:'ProofMark Site', desc:'Public brand entry point and project home.', status:'live', url:'/go/proofmark-site' },
];
return (
Platform
{spokes.length} components
);
};
/* ---------- Tweaks panel ---------- */
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"theme": "dark",
"density": "comfortable",
"accent": "#7cb0ff"
}/*EDITMODE-END*/;
const TweaksPanel = ({ open, values, onChange, onClose }) => {
if (!open) return null;
const opt = (current, choices, onPick, label) => (
{label}
{choices.map(c => (
onPick(c.v)} style={{
padding:'7px 11px', borderRadius:8,
background: current === c.v ? 'var(--text)' : 'var(--bg-elev-2)',
color: current === c.v ? 'var(--bg)' : 'var(--text)',
border:'1px solid ' + (current === c.v ? 'var(--text)' : 'var(--border)'),
fontSize:12, fontWeight:500, cursor:'pointer',
}}>{c.l}
))}
);
const accents = ['#7cb0ff','#5ee59b','#ffb366','#ff6b8a','#a57cff','#1e4fd6'];
return (
{opt(values.theme, [{ v:'dark', l:'Console (dark)' }, { v:'light', l:'Editorial (light)' }], v => onChange({ theme: v }), 'Theme')}
{opt(values.density, [{ v:'comfortable', l:'Comfortable' }, { v:'compact', l:'Compact' }], v => onChange({ density: v }), 'Density')}
Accent
{accents.map(a => (
onChange({ accent: a })} style={{ width:26, height:26, borderRadius:999, background:a, border:'2px solid ' + (values.accent === a ? 'var(--text)' : 'transparent'), cursor:'pointer' }}/>
))}
);
};
/* ---------- Main App ---------- */
const App = () => {
const [view, setView] = useState('home');
const [paletteOpen, setPaletteOpen] = useState(false);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const [tweaksOpen, setTweaksOpen] = useState(false);
const [selectedTool, setSelectedTool] = useState(null);
const [group, setGroup] = useState('all');
const [tweaks, setTweaks] = useState(() => {
try { return { ...TWEAK_DEFAULTS, ...JSON.parse(localStorage.getItem('pm_tweaks')||'{}') }; }
catch { return TWEAK_DEFAULTS; }
});
useEffect(() => {
document.body.dataset.theme = tweaks.theme;
document.body.style.setProperty('--accent', tweaks.accent);
document.body.style.setProperty('--accent-glow', tweaks.accent + '22');
try { localStorage.setItem('pm_tweaks', JSON.stringify(tweaks)); } catch {}
}, [tweaks]);
useEffect(() => {
const handler = (e) => {
if (!e.data || typeof e.data !== 'object') return;
if (e.data.type === '__activate_edit_mode') setTweaksOpen(true);
if (e.data.type === '__deactivate_edit_mode') setTweaksOpen(false);
};
window.addEventListener('message', handler);
window.parent.postMessage({ type:'__edit_mode_available' }, '*');
return () => window.removeEventListener('message', handler);
}, []);
const updateTweak = (patch) => {
setTweaks(prev => ({ ...prev, ...patch }));
window.parent.postMessage({ type:'__edit_mode_set_keys', edits: patch }, '*');
};
useEffect(() => {
const h = (e) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); setPaletteOpen(true); return; }
if (document.activeElement?.tagName === 'INPUT') return;
if (paletteOpen) return;
if (e.key === '?') { e.preventDefault(); setShortcutsOpen(v => !v); return; }
if (shortcutsOpen) return;
if (e.key === 'g' || e.key === 'G') setView('tools');
if (e.key === 'h' || e.key === 'H') setView('home');
if (e.key === 'p' || e.key === 'P') setView('pinned');
if (e.key === 'm' || e.key === 'M') setView('map');
};
window.addEventListener('keydown', h);
return () => window.removeEventListener('keydown', h);
}, [paletteOpen, shortcutsOpen]);
const onRun = (tool) => setSelectedTool(tool);
const breadcrumb = (() => {
if (view === 'home') return ['Workspace', 'Home'];
if (view === 'tools') return ['Workspace', 'All tools', group === 'all' ? 'All' : (window.PM_GROUPS.find(g=>g.id===group)?.label || 'All')];
if (view === 'pinned') return ['Workspace', 'Pinned'];
if (view === 'map') return ['Workspace', 'Platform'];
return ['Workspace'];
})();
return (
{
if (v.startsWith('tool:')) {
const slug = v.slice(5);
const tool = window.PM_TOOLS.find(t => t.slug === slug);
if (tool) setSelectedTool(tool);
} else setView(v);
}} onOpenPalette={() => setPaletteOpen(true)} density={tweaks.density}/>
setPaletteOpen(true)} onOpenTweaks={() => setTweaksOpen(true)} breadcrumb={breadcrumb}/>
{view === 'home' && (
setPaletteOpen(true)}/>
{}}/>
)}
{view === 'tools' && (
Catalog
All tools · {__pmVisible(window.PM_TOOLS).length}
Click a category to filter, or scroll through grouped sections below.
)}
{view === 'pinned' && (
Pinned tools
{__pmVisible(window.PM_TOOLS).filter(t=>t.pin).map(t => )}
)}
{view === 'map' && (
)}
{view === 'settings' && (
Settings Use the Tweaks panel (bottom-right) to adjust theme, density, and accent.
)}
setPaletteOpen(false)} onRun={onRun}/>
setShortcutsOpen(false)}/>
setSelectedTool(null)}/>
setTweaksOpen(false)}/>
);
};
/* ---------- Backend registry sync ----------
* Hub serves /api/tools as source of truth for { status, url } per slug.
* We merge that into the React catalog (window.PM_TOOLS) BEFORE first render
* so status pills + drawer targets reflect whatever the backend currently says.
* Falls back to hardcoded values after a 600ms timeout so offline dev still works. */
const __pmSyncFromBackend = async () => {
try {
const res = await fetch('/api/tools', { cache: 'no-store' });
if (!res.ok) return;
const payload = await res.json();
const serverTools = payload.tools || {};
window.PM_TOOLS.forEach(t => {
const s = serverTools[t.slug];
if (!s) return;
if (s.status) t.status = s.status;
if (s.url) t.url = s.url; // drawer keeps this for potential deep-link
// Flag-downgraded live tools carry `paused: true` so the pill can show it.
if (s.paused) t.paused = true;
// Display filter: beta/planned tiles hide by default (live-only catalog).
// Server flips `display:true` in roadmap mode (PROOFMARK_SHOW_ALL_TILES=true).
t.hidden = s.display === false;
});
} catch (err) {
console.warn('[registry] sync failed, using hardcoded catalog', err);
}
};
const __pmMount = () => {
ReactDOM.createRoot(document.getElementById('root')).render( );
};
Promise.race([
__pmSyncFromBackend(),
new Promise(resolve => setTimeout(resolve, 600)),
]).then(__pmMount);