0
TOOLS#e3869a68
InkWell - OCR Note Capture
@Owner·deposited 2w ago·updated 2w ago·28 views
TOOLS#e3869a68
InkWell - OCR Note Capture
OW
@Owner
28Views
0Comments
0Forks
0Saves
SHARE · REMIX
InkWell - OCR Note Capture — a HTML tools widget by @Owner.
CONTROLS
ocrnotesmarkdownhandwritingai
No comments yet. Be the first!
✦ Remix with AI
SDK in this widget
vibes.onReadyvibes.savevibes.loadvibes.deletevibes.listKeysvibes.fetch
- raw fetch(): Raw fetch() detected. Prefer vibes.fetch for proxied requests.
Generated prompt
You are helping me modify a vibe-coded widget from itjustvibes.com.
[VIBE CODE: "InkWell - OCR Note Capture" by @Owner]
Source: https://itjustvibes.com/Owner/inkwell-ocr-note-capture
Type: HTML
--- SOURCE CODE ---
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>InkWell — OCR Note Capture</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root{
--ink:#12161d; --slate:#1c222c; --slate-2:#242c38; --slate-3:#303a49;
--line:#39445580; --muted:#8b96a6; --muted-2:#5c6675;
--paper:#f6f1e5; --paper-2:#efe7d4; --paper-line:#e0d6bd; --paper-ink:#23201a;
--teal:#17b6a0; --teal-2:#0f9184; --red:#e0574d; --amber:#e0a43b;
--text:#e9edf3; --text-2:#c4ccd7;
--r:12px; --r-sm:8px;
--shadow:0 18px 50px -18px rgba(0,0,0,.6);
}
*{box-sizing:border-box}
html,body{margin:0;height:100%}
body{
background:
radial-gradient(120% 90% at 12% -10%, #232c3a 0%, transparent 55%),
radial-gradient(120% 100% at 100% 0%, #1d2530 0%, transparent 50%),
var(--ink);
color:var(--text);
font-family:"Space Grotesk",system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
font-size:15px; line-height:1.5; -webkit-font-smoothing:antialiased;
}
button{font-family:inherit;cursor:pointer;border:none;background:none;color:inherit}
input,textarea,select{font-family:inherit}
::selection{background:var(--teal);color:#04120f}
.mono{font-family:"IBM Plex Mono",ui-monospace,Menlo,monospace}
.app{display:flex;flex-direction:column;min-height:100vh;max-width:1180px;margin:0 auto}
/* ---- top bar ---- */
.top{display:flex;align-items:center;gap:16px;padding:16px 20px;border-bottom:1px solid var(--line)}
.brand{display:flex;align-items:center;gap:11px;font-weight:700;letter-spacing:-.3px;font-size:18px}
.mark{width:30px;height:30px;border-radius:8px;background:linear-gradient(150deg,var(--teal),var(--teal-2));
display:grid;place-items:center;color:#04120f;font-weight:700;box-shadow:0 4px 14px -4px var(--teal-2)}
.brand small{display:block;font-size:10px;letter-spacing:1.6px;text-transform:uppercase;color:var(--muted);font-weight:500;margin-top:2px}
.tabs{display:flex;gap:4px;margin-left:auto;background:var(--slate);padding:4px;border-radius:10px;border:1px solid var(--line)}
.tab{padding:8px 15px;border-radius:7px;color:var(--muted);font-weight:500;font-size:14px;display:flex;gap:7px;align-items:center;transition:.15s}
.tab:hover{color:var(--text-2)}
.tab.on{background:var(--slate-3);color:var(--text)}
.tab .dot{background:var(--teal);color:#04120f;border-radius:20px;font-size:11px;padding:1px 7px;font-weight:600}
/* ---- layout ---- */
.main{flex:1;padding:22px 20px 60px}
.view{display:none;animation:rise .25s ease}
.view.on{display:block}
@keyframes rise{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
.eyebrow{font-size:11px;letter-spacing:1.8px;text-transform:uppercase;color:var(--muted);font-weight:500;margin:0 0 6px}
h2.h{margin:0 0 4px;font-size:24px;letter-spacing:-.5px}
.sub{color:var(--muted);font-size:13.5px;margin:0 0 20px;max-width:60ch}
/* ---- buttons ---- */
.btn{display:inline-flex;align-items:center;gap:8px;padding:10px 16px;border-radius:var(--r-sm);
background:var(--slate-2);border:1px solid var(--line);color:var(--text-2);font-weight:500;font-size:14px;transition:.15s}
.btn:hover{background:var(--slate-3);color:var(--text)}
.btn.primary{background:linear-gradient(150deg,var(--teal),var(--teal-2));color:#04120f;border-color:transparent;font-weight:600}
.btn.primary:hover{filter:brightness(1.06)}
.btn.ghost{background:transparent}
.btn.danger{color:var(--red)}
.btn.danger:hover{background:#e0574d18;color:var(--red)}
.btn:disabled{opacity:.45;cursor:not-allowed}
.btn.sm{padding:7px 11px;font-size:13px}
.row{display:flex;gap:10px;flex-wrap:wrap}
/* ---- dropzone ---- */
.drop{border:1.5px dashed var(--slate-3);border-radius:16px;padding:40px 24px;text-align:center;
background:var(--slate)#00000000;transition:.18s;background:linear-gradient(180deg,#1e2530,#1a212b)}
.drop.hot{border-color:var(--teal);background:#17b6a012}
.drop .big{font-size:17px;font-weight:600;margin:12px 0 4px}
.drop .hint{color:var(--muted);font-size:13px}
.scan-ico{width:52px;height:52px;border-radius:14px;background:var(--slate-2);border:1px solid var(--line);
display:inline-grid;place-items:center;margin-bottom:4px}
/* ---- mode bar ---- */
.modebar{display:flex;align-items:center;gap:14px;margin:-4px 0 18px;flex-wrap:wrap}
.modelbl{font-size:12px;color:var(--muted);font-weight:500}
/* ---- toggles ---- */
.opts{display:flex;flex-wrap:wrap;gap:8px;margin:16px 0}
.chip{display:inline-flex;align-items:center;gap:8px;padding:8px 12px;border-radius:20px;background:var(--slate);
border:1px solid var(--line);color:var(--muted);font-size:13px;font-weight:500;user-select:none}
.chip .sw{width:30px;height:17px;border-radius:20px;background:var(--slate-3);position:relative;transition:.18s;flex:none}
.chip .sw::after{content:"";position:absolute;top:2px;left:2px;width:13px;height:13px;border-radius:50%;background:var(--muted);transition:.18s}
.chip.on{color:var(--text);border-color:#17b6a066}
.chip.on .sw{background:var(--teal)}
.chip.on .sw::after{left:15px;background:#04120f}
/* ---- page queue ---- */
.pages{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:14px;margin-top:20px}
.page{background:var(--slate);border:1px solid var(--line);border-radius:var(--r);overflow:hidden;display:flex;flex-direction:column}
.page .thumb{position:relative;aspect-ratio:4/3;background:#0c0f14;overflow:hidden}
.page .thumb img{width:100%;height:100%;object-fit:cover;display:block}
.page .num{position:absolute;top:8px;left:8px;background:#000000aa;backdrop-filter:blur(4px);
border-radius:6px;padding:2px 8px;font-size:12px;font-weight:600}
.page .del{position:absolute;top:8px;right:8px;background:#000000aa;border-radius:6px;width:26px;height:26px;display:grid;place-items:center;color:#fff}
.page .del:hover{background:var(--red)}
.scanline{position:absolute;left:0;right:0;height:34%;top:-40%;
background:linear-gradient(180deg,transparent,#17b6a055,transparent);opacity:0;pointer-events:none}
.page.reading .scanline{opacity:1;animation:scan 1.15s linear infinite}
@keyframes scan{0%{top:-40%}100%{top:100%}}
.page .body{padding:11px 12px;display:flex;flex-direction:column;gap:8px;flex:1}
.page .txt{font-family:"IBM Plex Mono",monospace;font-size:11.5px;line-height:1.45;color:var(--text-2);
background:#0d1118;border:1px solid var(--line);border-radius:7px;padding:8px;max-height:120px;overflow:auto;white-space:pre-wrap;flex:1}
.page .st{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--muted)}
.badge{font-size:11px;padding:2px 8px;border-radius:20px;font-weight:600;display:inline-flex;gap:5px;align-items:center}
.badge.q{background:var(--slate-3);color:var(--muted)}
.badge.reading{background:#17b6a020;color:var(--teal)}
.badge.read{background:#e0a43b20;color:var(--amber)}
.badge.ok{background:#17b6a022;color:var(--teal)}
.badge.err{background:#e0574d22;color:var(--red)}
.sticky-actions{position:sticky;bottom:0;margin-top:22px;padding:14px 0;background:linear-gradient(180deg,transparent,var(--ink) 40%);
display:flex;gap:10px;flex-wrap:wrap;align-items:center}
.count{color:var(--muted);font-size:13px;margin-right:auto}
/* ---- library ---- */
.libtools{display:flex;gap:10px;margin-bottom:16px;flex-wrap:wrap}
.search{flex:1;min-width:180px;display:flex;align-items:center;gap:9px;background:var(--slate);border:1px solid var(--line);border-radius:var(--r-sm);padding:0 12px}
.search input{background:none;border:none;color:var(--text);padding:11px 0;width:100%;outline:none;font-size:14px}
.notes{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px}
.note{background:var(--slate);border:1px solid var(--line);border-radius:var(--r);padding:16px;cursor:pointer;transition:.15s;display:flex;flex-direction:column;gap:9px;min-height:150px}
.note:hover{border-color:#17b6a066;transform:translateY(-2px);box-shadow:var(--shadow)}
.note h3{margin:0;font-size:16px;letter-spacing:-.2px;line-height:1.25}
.note .pv{font-family:"IBM Plex Mono",monospace;font-size:12px;color:var(--muted);line-height:1.5;flex:1;
overflow:hidden;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical}
.note .meta{display:flex;align-items:center;gap:8px;font-size:11.5px;color:var(--muted-2);flex-wrap:wrap}
.tag{font-size:11px;background:var(--slate-3);color:var(--text-2);padding:2px 8px;border-radius:20px;font-weight:500}
.empty{text-align:center;padding:60px 20px;color:var(--muted)}
.empty svg{opacity:.5;margin-bottom:12px}
/* ---- editor ---- */
.ed-head{display:flex;gap:12px;align-items:flex-start;margin-bottom:14px;flex-wrap:wrap}
.ed-title{flex:1;min-width:220px}
.ed-title input{width:100%;background:none;border:none;color:var(--text);font-size:22px;font-weight:600;letter-spacing:-.4px;outline:none;padding:2px 0}
.ed-title input::placeholder{color:var(--muted-2)}
.tagbar{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:6px}
.tagbar input{background:none;border:none;color:var(--muted);outline:none;font-size:12.5px;font-family:"IBM Plex Mono",monospace;min-width:90px;flex:1}
.tag.rm{cursor:pointer}
.tag.rm:hover{background:var(--red);color:#fff}
.seg{display:flex;background:var(--slate);border:1px solid var(--line);border-radius:8px;padding:3px}
.seg button{padding:6px 13px;border-radius:6px;font-size:13px;color:var(--muted);font-weight:500}
.seg button.on{background:var(--slate-3);color:var(--text)}
.panes{display:grid;grid-template-columns:1fr 1fr;gap:16px;align-items:start}
.pane-src textarea{width:100%;min-height:60vh;background:#0d1118;border:1px solid var(--line);border-radius:var(--r);
color:var(--text-2);font-family:"IBM Plex Mono",monospace;font-size:13px;line-height:1.65;padding:16px;resize:vertical;outline:none;tab-size:2}
.pane-src textarea:focus{border-color:#17b6a066}
.paper{background:var(--paper);color:var(--paper-ink);border-radius:var(--r);padding:34px 38px;min-height:60vh;
box-shadow:var(--shadow),inset 0 0 0 1px #00000010;position:relative;overflow:hidden;
background-image:linear-gradient(180deg,#fdfaf2,var(--paper))}
.paper::before{content:"";position:absolute;left:0;top:0;bottom:0;width:40px;background:linear-gradient(90deg,#e9dfc6,transparent);opacity:.5}
.paper .fm{font-family:"IBM Plex Mono",monospace;font-size:11.5px;color:#8a7f66;border-bottom:1px solid var(--paper-line);padding-bottom:12px;margin-bottom:18px;white-space:pre-wrap}
.paper h1,.paper h2,.paper h3,.paper h4{font-family:"Space Grotesk",sans-serif;color:#1a1712;letter-spacing:-.3px;margin:1.1em 0 .4em;line-height:1.2}
.paper h1{font-size:26px}.paper h2{font-size:21px}.paper h3{font-size:17px}
.paper p,.paper li{font-family:Georgia,"Times New Roman",serif;font-size:15.5px;line-height:1.72}
.paper ul,.paper ol{padding-left:22px}
.paper li{margin:.2em 0}
.paper code{font-family:"IBM Plex Mono",monospace;font-size:.85em;background:#e7ddc6;padding:1px 5px;border-radius:4px}
.paper pre{background:#efe7d2;border:1px solid var(--paper-line);border-radius:8px;padding:12px 14px;overflow:auto}
.paper pre code{background:none;padding:0}
.paper blockquote{border-left:3px solid var(--amber);margin:.6em 0;padding:2px 0 2px 16px;color:#5f5744;font-style:italic}
.paper hr{border:none;border-top:1px solid var(--paper-line);margin:1.4em 0}
.paper a{color:var(--teal-2)}
.ed-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:18px;padding-top:16px;border-top:1px solid var(--line)}
.savestate{font-size:12px;color:var(--muted);display:flex;align-items:center;gap:6px;margin-right:auto}
.savestate .d{width:7px;height:7px;border-radius:50%;background:var(--teal)}
.savestate.dirty .d{background:var(--amber)}
/* ---- settings ---- */
.card{background:var(--slate);border:1px solid var(--line);border-radius:var(--r);padding:20px 22px;margin-bottom:16px}
.card h4{margin:0 0 4px;font-size:15px}
.card .cd{color:var(--muted);font-size:12.5px;margin:0 0 16px}
.field{margin-bottom:14px}
.field label{display:block;font-size:12px;color:var(--text-2);margin-bottom:6px;font-weight:500}
.field .hlp{font-size:11.5px;color:var(--muted-2);margin-top:5px}
.inp{width:100%;background:#0d1118;border:1px solid var(--line);border-radius:var(--r-sm);color:var(--text);
padding:11px 13px;outline:none;font-size:14px;transition:.15s}
.inp:focus{border-color:#17b6a066}
textarea.inp{font-family:"IBM Plex Mono",monospace;font-size:12.5px;line-height:1.55;resize:vertical;min-height:90px}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.togline{display:flex;justify-content:space-between;align-items:center;gap:14px;padding:11px 0;border-bottom:1px solid var(--line)}
.togline:last-child{border-bottom:none}
.togline .t{font-size:13.5px}.togline .t small{display:block;color:var(--muted);font-size:11.5px;margin-top:2px}
.usage{font-size:12px;color:var(--muted);font-family:"IBM Plex Mono",monospace}
/* ---- toast + modal ---- */
.toasts{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:60;display:flex;flex-direction:column;gap:8px;align-items:center;pointer-events:none}
.toast{background:var(--slate-3);border:1px solid var(--line);border-radius:10px;padding:11px 16px;font-size:13.5px;
box-shadow:var(--shadow);animation:pop .2s ease;max-width:90vw}
.toast.err{border-color:#e0574d66;color:#ffb4ae}
.toast.ok{border-color:#17b6a066}
@keyframes pop{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
.scrim{position:fixed;inset:0;background:#0009;backdrop-filter:blur(3px);z-index:50;display:none;place-items:center;padding:20px}
.scrim.on{display:grid}
.modal{background:var(--slate);border:1px solid var(--line);border-radius:16px;padding:24px;max-width:440px;width:100%;box-shadow:var(--shadow)}
.modal h3{margin:0 0 8px}.modal p{color:var(--muted);font-size:13.5px;margin:0 0 18px}
.spin{width:15px;height:15px;border:2px solid #ffffff30;border-top-color:var(--teal);border-radius:50%;animation:sp .7s linear infinite;display:inline-block}
@keyframes sp{to{transform:rotate(360deg)}}
.banner{display:flex;gap:10px;align-items:flex-start;background:#e0a43b14;border:1px solid #e0a43b44;border-radius:10px;padding:12px 14px;font-size:13px;color:#e9d3a0;margin-bottom:18px}
.banner b{color:#f3dfb0}
:focus-visible{outline:2px solid var(--teal);outline-offset:2px;border-radius:4px}
@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
@media (max-width:820px){
.panes{grid-template-columns:1fr}
.grid2{grid-template-columns:1fr}
.brand small{display:none}
.top{flex-wrap:wrap}
.tabs{margin-left:0;width:100%;justify-content:space-between}
.tab{flex:1;justify-content:center}
}
</style>
</head>
<body>
<div class="app">
<header class="top">
<div class="brand">
<span class="mark">✒</span>
<span>InkWell<small>OCR note capture</small></span>
</div>
<nav class="tabs" id="tabs">
<button class="tab on" data-tab="capture">Capture</button>
<button class="tab" data-tab="library">Library <span class="dot" id="libCount" hidden>0</span></button>
<button class="tab" data-tab="settings">Settings</button>
</nav>
</header>
<main class="main">
<!-- CAPTURE -->
<section class="view on" id="v-capture">
<p class="eyebrow">Scan · Read · Verify</p>
<h2 class="h">Capture a note from photos</h2>
<p class="sub">Add one or more photos of a note. InkWell reads the text, lets you verify it against the image, then merges every page into one continuous document you can edit and save.</p>
<div class="modebar">
<span class="modelbl">What are you reading?</span>
<div class="seg" id="modeSeg">
<button data-mode="printed" class="on">Printed text</button>
<button data-mode="handwritten">Handwriting</button>
<button data-mode="mixed">Mixed</button>
</div>
</div>
<div class="drop" id="drop" tabindex="0" role="button" aria-label="Add photos">
<span class="scan-ico">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#17b6a0" stroke-width="1.7"><path d="M3 8V5a2 2 0 0 1 2-2h3M16 3h3a2 2 0 0 1 2 2v3M21 16v3a2 2 0 0 1-2 2h-3M8 21H5a2 2 0 0 1-2-2v-3"/><path d="M3 12h18"/></svg>
</span>
<div class="big">Drop photos here, or choose a source</div>
<div class="hint">JPG / PNG / HEIC · handwriting or print · paste with ⌘V works too</div>
<div class="row" style="justify-content:center;margin-top:16px">
<button class="btn primary" id="pickFiles">Add photos</button>
<button class="btn" id="pickCamera">Take photo</button>
</div>
</div>
<input type="file" id="fileInput" accept="image/*" multiple hidden>
<input type="file" id="camInput" accept="image/*" capture="environment" hidden>
<div class="opts">
<label class="chip" data-opt="autoIntake"><span class="sw"></span> Auto-read on add</label>
<label class="chip" data-opt="autoVerify"><span class="sw"></span> Auto-verify against image</label>
<label class="chip" data-opt="enhanceImages"><span class="sw"></span> Sharpen for print</label>
<label class="chip" data-opt="autospellOnImport"><span class="sw"></span> Quick spell-fix on import</label>
<label class="chip" data-opt="pageDividers"><span class="sw"></span> Insert page markers</label>
</div>
<div class="pages" id="pageGrid"></div>
<div class="sticky-actions" id="captureActions" hidden>
<span class="count" id="pageCount">0 pages</span>
<button class="btn" id="readAll">Read all</button>
<button class="btn" id="verifyAll">Verify all</button>
<button class="btn ghost danger" id="clearPages">Clear</button>
<button class="btn primary" id="combine">Combine into note →</button>
</div>
</section>
<!-- LIBRARY -->
<section class="view" id="v-library">
<p class="eyebrow">Saved per user · synced to agent</p>
<h2 class="h">Your notes</h2>
<p class="sub">A running list of everything you've captured. Notes are stored privately with the Vibes SDK and (optionally) mirrored so an MCP agent can read them.</p>
<div class="libtools">
<div class="search">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#8b96a6" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
<input id="libSearch" placeholder="Search notes, tags, text…">
</div>
<button class="btn" id="newNote">New blank note</button>
<button class="btn" id="exportCsv">Export all (CSV)</button>
</div>
<div class="notes" id="noteGrid"></div>
</section>
<!-- EDITOR -->
<section class="view" id="v-editor">
<button class="btn ghost sm" id="backLib" style="margin-bottom:14px">← Library</button>
<div class="ed-head">
<div class="ed-title">
<input id="edTitle" placeholder="Untitled note">
<div class="tagbar" id="tagbar">
<input id="tagInput" placeholder="add tag + Enter">
</div>
</div>
<div class="seg" id="edSeg">
<button data-mode="split" class="on">Split</button>
<button data-mode="src">Source</button>
<button data-mode="doc">Document</button>
</div>
</div>
<div class="panes" id="panes">
<div class="pane-src" id="paneSrc"><textarea id="edSource" spellcheck="true" placeholder="Transcribed text appears here as Markdown…"></textarea></div>
<div id="paneDoc"><div class="paper" id="preview"></div></div>
</div>
<div class="ed-actions">
<span class="savestate" id="saveState"><span class="d"></span> Saved</span>
<button class="btn" id="aiClean">✦ AI clean-up + YAML</button>
<button class="btn" id="quickSpell">Quick spell-fix</button>
<button class="btn" id="copyForAI">Copy for AI</button>
<button class="btn" id="copyMd">Copy Markdown</button>
<button class="btn" id="dlMd">Download .md</button>
<button class="btn" id="savePdf">Save as PDF</button>
<button class="btn ghost danger" id="delNote">Delete</button>
</div>
</section>
<!-- SETTINGS -->
<section class="view" id="v-settings">
<p class="eyebrow">Configure the AI · bring your own key</p>
<h2 class="h">Settings</h2>
<p class="sub">InkWell reads text with any OpenAI-compatible vision model. Pick a provider, paste a key, and set a model name. Calls are routed through the Vibes fetch proxy so cross-origin providers work.</p>
<div class="banner" id="agentBanner">
<span aria-hidden="true">◆</span>
<div><b>For the MCP agent:</b> the widget owner must enable agent access in <b>Manage → Agent tab</b>. Once on, your saved notes are readable by an agent through <span class="mono">vibes.ai</span> state.</div>
</div>
<div class="card">
<h4>AI provider</h4>
<p class="cd">Model names are examples only — enter whatever your provider currently offers.</p>
<div class="field">
<label>Provider preset</label>
<select class="inp" id="cfgProvider">
<option value="openai">OpenAI</option>
<option value="openrouter">OpenRouter</option>
<option value="groq">Groq</option>
<option value="gemini">Google Gemini (free tier · recommended)</option>
<option value="custom">Custom / self-hosted</option>
</select>
<div class="hlp" id="providerHint" style="margin-top:8px;display:none"></div>
</div>
<div class="field">
<label>API base URL</label>
<input class="inp" id="cfgBase" placeholder="https://api.provider.com/v1">
</div>
<div class="field">
<label>API key</label>
<input class="inp" id="cfgKey" type="password" placeholder="sk-…" autocomplete="off">
<div class="hlp">Stored per user with the Vibes SDK. Never shared with other users.</div>
</div>
<div class="grid2">
<div class="field">
<label>Vision model (OCR)</label>
<input class="inp" id="cfgVision" placeholder="e.g. a vision-capable model">
</div>
<div class="field">
<label>Text model (clean-up)</label>
<input class="inp" id="cfgText" placeholder="e.g. a fast text model">
</div>
</div>
<div class="row">
<button class="btn primary" id="cfgSave">Save settings</button>
<button class="btn" id="cfgTest">Test connection</button>
<span id="testOut" class="usage" style="align-self:center"></span>
</div>
</div>
<div class="card">
<h4>Recognition engine</h4>
<p class="cd">Where text is read from your photos. The AI layer only re-formats the result afterwards.</p>
<select class="inp" id="cfgEngine" style="margin-bottom:12px">
<option value="cloud">Cloud vision — best quality, incl. handwriting (needs a key)</option>
<option value="tesseract">On-device: Tesseract.js — runs locally, any browser (printed text)</option>
<option value="chrome">On-device: Chrome built-in AI — Gemini Nano, no key (Chrome only)</option>
</select>
<div class="hlp" id="engineNote" style="margin-bottom:14px"></div>
<div class="togline"><div class="t">Auto-format after capture<small>Run the AI clean-up + YAML layer automatically once pages are combined into a note.</small></div><label class="chip" data-opt="autoFormat"><span class="sw"></span></label></div>
</div>
<div class="card">
<h4>Behaviour</h4>
<div class="togline"><div class="t">Route through Vibes proxy<small>Needed for most providers (CORS). Turn off only for CORS-enabled endpoints.</small></div><label class="chip" data-opt="useProxy"><span class="sw"></span></label></div>
<div class="togline"><div class="t">Mirror notes to MCP agent<small>Writes each note to <span class="mono">vibes.ai</span> so an agent can pull it.</small></div><label class="chip" data-opt="mirrorAgent"><span class="sw"></span></label></div>
<div class="togline"><div class="t">Auto-read images on add<small>Runs OCR the moment a photo is added.</small></div><label class="chip" data-opt="autoIntake"><span class="sw"></span></label></div>
<div class="togline"><div class="t">Auto-verify against image<small>Second pass re-checks the reading; slower, more reliable.</small></div><label class="chip" data-opt="autoVerify"><span class="sw"></span></label></div>
</div>
<div class="card">
<h4>Prompts</h4>
<p class="cd">Tune how text is read and how it's standardised.</p>
<div class="field"><label>Custom OCR prompt (optional)</label><textarea class="inp" id="cfgOcr" rows="4" placeholder="Leave blank to use the built-in Printed / Handwriting / Mixed presets. Fill this in only to override them."></textarea><div class="hlp">Blank = smart presets chosen by the document-type switch on the Capture tab.</div></div>
<div class="field"><label>Clean-up prompt (structure + YAML tags)</label><textarea class="inp" id="cfgClean" rows="6"></textarea></div>
<button class="btn" id="cfgSavePrompts">Save prompts</button>
</div>
<div class="card">
<h4>Storage</h4>
<p class="cd" id="storageInfo">—</p>
<button class="btn ghost danger" id="wipe">Delete all notes</button>
</div>
</section>
</main>
</div>
<div class="toasts" id="toasts"></div>
<div class="scrim" id="scrim"><div class="modal" id="modal"></div></div>
<script>
"use strict";
/* ============================ SDK / storage layer ============================ */
const hasVibes = typeof window !== "undefined" && !!window.vibes;
const mem = {}; // in-memory fallback (preview/testing only)
const store = {
async save(k,v){ if(hasVibes) return vibes.save(k,v); mem[k]=JSON.parse(JSON.stringify(v)); },
async load(k){ if(hasVibes) return await vibes.load(k); return (k in mem)? JSON.parse(JSON.stringify(mem[k])) : null; },
async delete(k){ if(hasVibes) return vibes.delete(k); delete mem[k]; },
async listKeys(){ if(hasVibes) return await vibes.listKeys(); return Object.keys(mem); }
};
const aiStore = {
async set(k,v){ try{ if(hasVibes && vibes.ai) return await vibes.ai.setState(k,v); }catch(e){ console.warn("ai.setState",e.message); } },
async get(k){ try{ if(hasVibes && vibes.ai) return await vibes.ai.getState(k); }catch(e){} return null; },
async list(){ try{ if(hasVibes && vibes.ai) return await vibes.ai.listKeys(); }catch(e){} return []; }
};
/* ============================ config ============================ */
const OCR_PROMPTS = {
printed:`You are a high-accuracy OCR engine reading a photo or scan of a PRINTED document. Transcribe every piece of text exactly as printed, in natural reading order. Preserve line breaks, headings, bullet and numbered lists, and table structure (use simple aligned columns or Markdown tables). Keep punctuation, numbers, symbols and capitalisation precise. Do not translate, summarise, correct spelling, or add commentary. If a region is genuinely unreadable, write [?]. Output only the transcription.`,
handwritten:`You are an expert transcriber of HANDWRITTEN notes — cursive, print, or messy and rushed handwriting. Read the whole image carefully and transcribe everything, using sentence context and common sense to resolve ambiguous letters and words. Preserve line breaks and list structure. Keep names, numbers and dates as written. Only where a word is truly illegible, write [?]. Do not summarise, translate, or add commentary. Output only the transcription.`,
mixed:`You are a high-accuracy OCR engine. The image may contain both PRINTED text and HANDWRITTEN notes or margin annotations. Transcribe all of it in natural reading order, using context to resolve unclear handwriting. Preserve line breaks, headings and lists. Keep numbers, names and dates precise. Mark truly illegible words as [?]. Do not summarise or comment. Output only the transcription.`
};
const DEFAULT_CLEAN = `Convert the following OCR-captured note into a clean, well-structured Markdown document.
Rules:
- Begin with YAML frontmatter containing: title, date (YYYY-MM-DD), tags (an array of 2–5 lowercase keywords), and source: ocr-capture.
- Infer a concise, descriptive title.
- Correct spelling and obvious OCR errors, but never invent facts or add content not implied by the source.
- Organise with headings, short paragraphs and bullet lists where it improves clarity.
- Preserve numbers, names, dates and lists faithfully.
- Return ONLY the Markdown document — no explanations, no surrounding code fences.`;
const PROVIDERS = {
openai: {base:"https://api.openai.com/v1"},
openrouter:{base:"https://openrouter.ai/api/v1"},
groq: {base:"https://api.groq.com/openai/v1"},
gemini: {base:"https://generativelanguage.googleapis.com/v1beta/openai"},
custom: {base:""}
};
const DEFAULT_CFG = {
provider:"openrouter", baseUrl:PROVIDERS.openrouter.base, apiKey:"",
visionModel:"", textModel:"", useProxy:true,
engine:"cloud", autoFormat:false,
captureMode:"printed", enhanceImages:false,
autoIntake:true, autoVerify:false, autospellOnImport:true, pageDividers:false,
mirrorAgent:true, ocrPrompt:"", cleanupPrompt:DEFAULT_CLEAN
};
const state = { cfg:{...DEFAULT_CFG}, index:[], pages:[], note:null, dirty:false, edMode:"split" };
const $ = s => document.querySelector(s);
const $$ = s => Array.from(document.querySelectorAll(s));
const uid = p => (p||"n")+"-"+Date.now().toString(36)+"-"+Math.random().toString(36).slice(2,7);
/* ============================ helpers ============================ */
function esc(s){ return String(s).replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c])); }
function countWords(t){ const m=(t||"").replace(/---[\s\S]*?---/,"").match(/\b[\w']+\b/g); return m?m.length:0; }
function fmtDate(ts){ const d=new Date(ts); return d.toLocaleDateString(undefined,{month:"short",day:"numeric",year:"numeric"}); }
function toast(msg,kind){ const t=document.createElement("div"); t.className="toast "+(kind||""); t.textContent=msg;
$("#toasts").appendChild(t); setTimeout(()=>{t.style.opacity="0";t.style.transition="opacity .3s";setTimeout(()=>t.remove(),300);}, kind==="err"?4200:2600); }
function confirmModal(title,body,okLabel){
return new Promise(res=>{
$("#modal").innerHTML=`<h3>${esc(title)}</h3><p>${esc(body)}</p>
<div class="row" style="justify-content:flex-end"><button class="btn ghost" id="mCancel">Cancel</button><button class="btn primary" id="mOk">${esc(okLabel||"Confirm")}</button></div>`;
$("#scrim").classList.add("on");
const close=v=>{ $("#scrim").classList.remove("on"); res(v); };
$("#mOk").onclick=()=>close(true); $("#mCancel").onclick=()=>close(false);
$("#scrim").onclick=e=>{ if(e.target===$("#scrim")) close(false); };
});
}
/* ============================ AI / API ============================ */
async function chat(messages,{model,maxTokens=4000,temperature=0}={}){
const c=state.cfg;
if(!c.apiKey) throw new Error("No API key set — open Settings to configure your provider.");
if(!model) throw new Error("No model set — enter a model name in Settings.");
if(!c.baseUrl) throw new Error("No API base URL set — open Settings.");
const url=c.baseUrl.replace(/\/$/,"")+"/chat/completions";
const headers={"Content-Type":"application/json","Authorization":"Bearer "+c.apiKey};
const body=JSON.stringify({model,messages,max_tokens:maxTokens,temperature});
let resp;
if(c.useProxy && hasVibes && vibes.fetch){
resp=await vibes.fetch(url,{method:"POST",headers,body,timeout:120000});
} else {
resp=await fetch(url,{method:"POST",headers,body});
}
let data; try{ data=await resp.json(); }catch(e){ throw new Error("API returned a non-JSON response ("+resp.status+")"); }
if(!resp.ok){ throw new Error((data && data.error && data.error.message) || ("API error "+resp.status)); }
const content=data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content;
if(typeof content!=="string") throw new Error("Unexpected API response shape.");
return content;
}
function stripFences(s){ const m=s.trim().match(/^```[a-zA-Z]*\n([\s\S]*?)\n```$/); return m?m[1]:s.trim(); }
function ocrPromptFor(mode){
const custom=(state.cfg.ocrPrompt||"").trim();
return custom || OCR_PROMPTS[mode] || OCR_PROMPTS.printed;
}
function looksEmpty(t){ const s=(t||"").trim(); return !s || s.length<2 || /^\[\?\]$/.test(s); }
function looksRefusal(t){ return /\b(i(?:'m| am)? (?:sorry|unable)|i can(?:'|no)?t (?:read|see|help|process|assist)|i cannot (?:read|see|assist|help)|as an ai|there (?:is|'s) no (?:visible|readable|legible) text|no text (?:is )?(?:visible|present|found))\b/i.test(t||""); }
async function ocrImage(dataUrl){
return (await chat([{role:"user",content:[
{type:"text",text:ocrPromptFor(state.cfg.captureMode)},
{type:"image_url",image_url:{url:dataUrl,detail:"high"}}
]}],{model:state.cfg.visionModel,temperature:0,maxTokens:4096})).trim();
}
async function verifyImage(dataUrl,draft){
return (await chat([{role:"user",content:[
{type:"text",text:"Below is a draft transcription of the attached image. Compare it to the image and return the corrected transcription that matches the image exactly — fix misread characters and words, keep layout. Return only the transcription.\n\nDraft:\n"+draft},
{type:"image_url",image_url:{url:dataUrl,detail:"high"}}
]}],{model:state.cfg.visionModel,temperature:0,maxTokens:4096})).trim();
}
async function cleanupNote(text){
const full=state.cfg.cleanupPrompt+"\n\n---\n\n"+text;
if(state.cfg.apiKey && state.cfg.textModel){
const out=await chat([
{role:"system",content:"You are a meticulous note editor. You turn raw OCR text into standardised Markdown without inventing content."},
{role:"user",content:full}
],{model:state.cfg.textModel,temperature:0.2});
return stripFences(out);
}
if(chromeAIAvailable()){
const out=await nanoTextComplete("You are a meticulous note editor that turns raw OCR text into standardised Markdown without inventing content.\n\n"+full);
return stripFences(out);
}
throw new Error("No text model configured — set one in Settings, or use a browser with built-in AI.");
}
/* ============================ image util ============================ */
// Decode with correct EXIF orientation (phone photos are often rotated → gibberish OCR).
async function loadBitmap(file){
if("createImageBitmap" in window){
try{ return await createImageBitmap(file,{imageOrientation:"from-image"}); }catch(e){}
try{ return await createImageBitmap(file); }catch(e){}
}
return await new Promise((res,rej)=>{ const img=new Image(); const u=URL.createObjectURL(file);
img.onload=()=>{ URL.revokeObjectURL(u); res(img); };
img.onerror=()=>{ URL.revokeObjectURL(u); rej(new Error("Could not decode image")); };
img.src=u; });
}
async function prepareCanvas(file,{maxDim=2200,enhance=false}={}){
if(!file.type||!file.type.startsWith("image/")) throw new Error("Not an image file");
let bmp;
try{ bmp=await loadBitmap(file); }
catch(e){ throw new Error("Could not read this image — HEIC/unsupported format? Try a JPG or PNG."); }
let w=bmp.width||bmp.naturalWidth, h=bmp.height||bmp.naturalHeight;
const s=Math.min(1,maxDim/Math.max(w,h)); const cw=Math.max(1,Math.round(w*s)), ch=Math.max(1,Math.round(h*s));
const c=document.createElement("canvas"); c.width=cw; c.height=ch;
const ctx=c.getContext("2d"); ctx.imageSmoothingQuality="high"; ctx.drawImage(bmp,0,0,cw,ch);
if(bmp.close) bmp.close();
let enhanced=false;
if(enhance){
try{
const im=ctx.getImageData(0,0,cw,ch), d=im.data, C=1.45, I=128*(1-C);
for(let i=0;i<d.length;i+=4){ let g=0.299*d[i]+0.587*d[i+1]+0.114*d[i+2]; g=g*C+I; g=g<0?0:g>255?255:g; d[i]=d[i+1]=d[i+2]=g; }
ctx.putImageData(im,0,0); enhanced=true;
}catch(e){}
}
return {canvas:c, enhanced};
}
async function prepareImage(file,opts={}){
const {canvas,enhanced}=await prepareCanvas(file,opts);
return canvas.toDataURL(enhanced?"image/png":"image/jpeg",0.92);
}
/* ============================ on-device engines ============================ */
// Load an external script once (CSP permitting) and cache the promise.
const _scriptCache={};
function loadScript(src){
if(_scriptCache[src]) return _scriptCache[src];
_scriptCache[src]=new Promise((res,rej)=>{
const s=document.createElement("script"); s.src=src; s.async=true;
s.onload=()=>res(); s.onerror=()=>rej(new Error("Couldn't load "+src+" — the page may block external scripts."));
document.head.appendChild(s);
});
return _scriptCache[src];
}
// --- Tesseract.js: real on-device OCR (WASM), works in any browser ---
const TESSERACT_SRC="https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js";
let _tessWorker=null;
async function getTessWorker(){
if(_tessWorker) return _tessWorker;
if(!_scriptCache[TESSERACT_SRC]) toast("Loading on-device OCR engine (first run — a few seconds)…");
await loadScript(TESSERACT_SRC);
if(!window.Tesseract) throw new Error("Tesseract failed to initialise.");
_tessWorker=await window.Tesseract.createWorker("eng");
return _tessWorker;
}
async function ocrTesseract(canvas){
const w=await getTessWorker();
const { data }=await w.recognize(canvas);
return (data&&data.text?data.text:"").trim();
}
// --- Chrome built-in AI (Gemini Nano): on-device, no key ---
function chromeAIAvailable(){ return typeof self!=="undefined" && ("LanguageModel" in self); }
async function chromeAIStatus(){
if(!chromeAIAvailable()) return "unsupported";
try{ return await self.LanguageModel.availability(); }catch(e){ return "unsupported"; }
}
let _nanoText=null;
function nanoMonitor(m){ try{ m.addEventListener("downloadprogress",e=>toast("Downloading on-device model… "+Math.round((e.loaded||0)*100)+"%")); }catch(_){} }
async function nanoTextComplete(prompt){
if(!chromeAIAvailable()) throw new Error("Chrome built-in AI isn't available in this browser.");
const st=await self.LanguageModel.availability();
if(st==="unavailable") throw new Error("Chrome built-in AI is unavailable on this device.");
if(!_nanoText) _nanoText=await self.LanguageModel.create(st==="available"?{}:{monitor:nanoMonitor});
return (await _nanoText.prompt(prompt)).trim();
}
async function ocrChromeAI(canvas){
if(!chromeAIAvailable()) throw new Error("Chrome built-in AI isn't available in this browser.");
const st=await self.LanguageModel.availability();
if(st==="unavailable") throw new Error("Chrome built-in AI is unavailable on this device.");
const opts={expectedInputs:[{type:"image"}]}; if(st!=="available") opts.monitor=nanoMonitor;
const session=await self.LanguageModel.create(opts);
try{
const out=await session.prompt([{role:"user",content:[
{type:"text",value:ocrPromptFor(state.cfg.captureMode)},
{type:"image",value:canvas}
]}]);
return (out||"").trim();
} finally { try{ session.destroy&&session.destroy(); }catch(_){} }
}
function canFormat(){ return (!!state.cfg.apiKey && !!state.cfg.textModel) || chromeAIAvailable(); }
/* ============================ markdown ============================ */
function inline(s){
s=esc(s);
s=s.replace(/`([^`]+)`/g,"<code>$1</code>");
s=s.replace(/\*\*([^*]+)\*\*/g,"<strong>$1</strong>");
s=s.replace(/(^|[^*])\*([^*\n]+)\*/g,"$1<em>$2</em>");
s=s.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g,'<a href="$2" target="_blank" rel="noopener">$1</a>');
return s;
}
function renderMarkdown(md){
md=String(md||""); let fm=null, body=md;
const m=md.match(/^\s*---\n([\s\S]*?)\n---\n?/);
if(m){ fm=m[1]; body=md.slice(m[0].length); }
const lines=body.replace(/\r\n/g,"\n").split("\n");
let html="",inCode=false,codeBuf=[],listType=null;
const closeList=()=>{ if(listType){ html+=listType==="ul"?"</ul>":"</ol>"; listType=null; } };
for(let i=0;i<lines.length;i++){
const line=lines[i], fence=line.match(/^```(\w*)\s*$/);
if(fence){ if(!inCode){ closeList(); inCode=true; codeBuf=[]; } else { html+="<pre><code>"+esc(codeBuf.join("\n"))+"</code></pre>"; inCode=false; } continue; }
if(inCode){ codeBuf.push(line); continue; }
if(/^\s*$/.test(line)){ closeList(); continue; }
if(/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)){ closeList(); html+="<hr>"; continue; }
const h=line.match(/^(#{1,6})\s+(.*)$/);
if(h){ closeList(); const l=h[1].length; html+="<h"+l+">"+inline(h[2])+"</h"+l+">"; continue; }
const bq=line.match(/^>\s?(.*)$/);
if(bq){ closeList(); html+="<blockquote>"+inline(bq[1])+"</blockquote>"; continue; }
const ul=line.match(/^\s*[-*+]\s+(.*)$/);
if(ul){ if(listType!=="ul"){ closeList(); html+="<ul>"; listType="ul"; } html+="<li>"+inline(ul[1])+"</li>"; continue; }
const ol=line.match(/^\s*\d+[.)]\s+(.*)$/);
if(ol){ if(listType!=="ol"){ closeList(); html+="<ol>"; listType="ol"; } html+="<li>"+inline(ol[1])+"</li>"; continue; }
closeList(); html+="<p>"+inline(line)+"</p>";
}
if(inCode) html+="<pre><code>"+esc(codeBuf.join("\n"))+"</code></pre>"; closeList();
const fmHtml=fm?('<div class="fm">'+esc(fm.trim())+"</div>"):"";
return fmHtml+(html||'<p style="color:#9a8f75">Nothing to preview yet.</p>');
}
function frontmatter(note){
const date=new Date(note.createdAt||Date.now()).toISOString().slice(0,10);
const tags=(note.tags&¬e.tags.length)?"["+note.tags.map(t=>JSON.stringify(t)).join(", ")+"]":"[]";
return `---\ntitle: ${JSON.stringify(note.title||"Untitled")}\ndate: ${date}\ntags: ${tags}\nsource: ocr-capture\n---\n\n`;
}
function toMarkdownFile(note){
const has=/^\s*---\n[\s\S]*?\n---/.test(note.content||"");
return has?note.content:frontmatter(note)+(note.content||"");
}
/* ============================ quick spell-fix (local) ============================ */
const TYPO={teh:"the",hte:"the",thge:"the",ther:"the",adn:"and",nad:"and",fo:"of",ot:"to",taht:"that",waht:"what",wnat:"want",
jsut:"just",liek:"like",baout:"about",aslo:"also",recieve:"receive",recieved:"received",seperate:"separate",definately:"definitely",
occured:"occurred",untill:"until",wich:"which",thier:"their",becuase:"because",beleive:"believe",freind:"friend",wierd:"weird",
acheive:"achieve",calender:"calendar",enviroment:"environment",goverment:"government",neccessary:"necessary",occassion:"occasion",
priviledge:"privilege",reccomend:"recommend",reccommend:"recommend",refered:"referred",succesful:"successful",succesfully:"successfully",
tommorow:"tomorrow",truely:"truly",accomodate:"accommodate",arguement:"argument",begining:"beginning",concious:"conscious",
embarass:"embarrass",existance:"existence",foriegn:"foreign",grammer:"grammar",harrass:"harass",independant:"independent",
knowlege:"knowledge",liason:"liaison",maintainance:"maintenance",millenium:"millennium",noticable:"noticeable",occurance:"occurrence",
persistant:"persistent",posession:"possession",prefered:"preferred",publically:"publicly",questionaire:"questionnaire",rythm:"rhythm",
threshhold:"threshold",writting:"writing",alot:"a lot",dont:"don't",doesnt:"doesn't",isnt:"isn't",wasnt:"wasn't",wouldnt:"wouldn't",
couldnt:"couldn't",shouldnt:"shouldn't",didnt:"didn't",wont:"won't",cant:"can't",hasnt:"hasn't",havent:"haven't"};
function fixWord(w){ const lo=w.toLowerCase(); if(!TYPO.hasOwnProperty(lo)) return w;
const r=TYPO[lo]; if(w[0]===w[0].toUpperCase()&&w.slice(1)===w.slice(1).toLowerCase()) return r[0].toUpperCase()+r.slice(1); return r; }
function applyAutospell(text){
let t=String(text).replace(/(^|\s)\|(\s|$)/g,"$1I$2");
t=t.replace(/[A-Za-z']+/g,fixWord);
t=t.replace(/[ \t]{2,}/g," ").replace(/ +([,.;:!?])/g,"$1");
return t;
}
/* ============================ notes CRUD + agent mirror ============================ */
function makePreview(c){ return String(c||"").replace(/^\s*---[\s\S]*?---\n?/,"").replace(/[#>*`_-]/g," ").replace(/\s+/g," ").trim().slice(0,180); }
async function loadIndex(){ const idx=await store.load("notes-index"); state.index=Array.isArray(idx)?idx:[]; refreshLibCount(); }
async function persistIndex(){
await store.save("notes-index",state.index);
if(state.cfg.mirrorAgent) await aiStore.set("index",state.index.map(n=>({id:n.id,title:n.title,tags:n.tags,updatedAt:n.updatedAt})));
}
async function saveNote(note){
note.updatedAt=Date.now();
await store.save("note-"+note.id,note);
const meta={id:note.id,title:note.title||"Untitled",createdAt:note.createdAt,updatedAt:note.updatedAt,
tags:note.tags||[],words:countWords(note.content),preview:makePreview(note.content)};
const i=state.index.findIndex(n=>n.id===note.id);
if(i>=0) state.index[i]=meta; else state.index.unshift(meta);
await persistIndex();
if(state.cfg.mirrorAgent) await aiStore.set(note.id,{title:note.title,tags:note.tags,content:toMarkdownFile(note),updatedAt:note.updatedAt});
refreshLibCount();
}
async function getNote(id){ return await store.load("note-"+id); }
async function removeNote(id){
await store.delete("note-"+id);
state.index=state.index.filter(n=>n.id!==id);
await persistIndex();
if(state.cfg.mirrorAgent) await aiStore.set(id,null);
refreshLibCount();
}
function refreshLibCount(){ const el=$("#libCount"); const n=state.index.length; el.textContent=n; el.hidden=n===0; }
/* ============================ tabs / nav ============================ */
function showTab(name){
$$(".tab").forEach(t=>t.classList.toggle("on",t.dataset.tab===name));
$$(".view").forEach(v=>v.classList.remove("on"));
$("#v-"+name).classList.add("on");
if(name==="library") renderLibrary();
if(name==="settings") fillSettings();
window.scrollTo({top:0,behavior:"smooth"});
}
$("#tabs").addEventListener("click",e=>{ const t=e.target.closest(".tab"); if(t) showTab(t.dataset.tab); });
/* ============================ capture ============================ */
function optChip(el){ el.classList.toggle("on",!!state.cfg[el.dataset.opt]); }
function syncOptChips(){ $$(".chip[data-opt]").forEach(optChip); }
$$('.opts .chip').forEach(c=>c.addEventListener("click",async()=>{
const k=c.dataset.opt; state.cfg[k]=!state.cfg[k]; optChip(c); await saveConfig();
}));
$("#modeSeg").addEventListener("click",async e=>{
const b=e.target.closest("button"); if(!b) return;
state.cfg.captureMode=b.dataset.mode;
$$("#modeSeg button").forEach(x=>x.classList.toggle("on",x===b));
state.cfg.enhanceImages = b.dataset.mode==="printed"; // sharpening helps print, can hurt faint pencil
await saveConfig(); syncOptChips();
});
$("#pickFiles").onclick=()=>$("#fileInput").click();
$("#pickCamera").onclick=()=>$("#camInput").click();
$("#fileInput").onchange=e=>addFiles(e.target.files);
$("#camInput").onchange=e=>addFiles(e.target.files);
const drop=$("#drop");
["dragenter","dragover"].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.add("hot");}));
["dragleave","drop"].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.remove("hot");}));
drop.addEventListener("drop",e=>addFiles(e.dataTransfer.files));
drop.addEventListener("click",e=>{ if(e.target===drop||e.target.classList.contains("big")||e.target.classList.contains("hint")||e.target.classList.contains("scan-ico")) $("#fileInput").click(); });
drop.addEventListener("keydown",e=>{ if(e.key==="Enter"||e.key===" ") $("#fileInput").click(); });
window.addEventListener("paste",e=>{ const items=e.clipboardData&&e.clipboardData.files; if(items&&items.length){ addFiles(items); } });
async function addFiles(fileList){
const files=Array.from(fileList||[]).filter(f=>f.type&&f.type.startsWith("image/"));
if(!files.length){ toast("No images found — add JPG or PNG photos.","err"); return; }
for(const f of files){
let thumb; try{ thumb=await prepareImage(f,{maxDim:720}); }catch(err){ toast(err.message||("Couldn't read "+(f.name||"image")),"err"); continue; }
state.pages.push({id:uid("p"),file:f,thumb,text:"",status:"queued"});
}
renderPages();
if(state.cfg.autoIntake) readPages(state.pages.filter(p=>p.status==="queued"));
}
function renderPages(){
const grid=$("#pageGrid"); grid.innerHTML="";
state.pages.forEach((p,idx)=>{
const el=document.createElement("div");
el.className="page"+(p.status==="reading"?" reading":"");
const badge = p.status==="reading" ? '<span class="badge reading"><span class="spin"></span>Reading</span>'
: p.status==="verifying" ? '<span class="badge reading"><span class="spin"></span>Verifying</span>'
: p.status==="verified" ? '<span class="badge ok">✓ Verified</span>'
: p.status==="read" ? '<span class="badge read">Read — verify?</span>'
: p.status==="error" ? '<span class="badge err">Failed</span>'
: '<span class="badge q">Queued</span>';
el.innerHTML=`
<div class="thumb"><div class="scanline"></div><img src="${p.thumb}" alt="page ${idx+1}">
<span class="num">Page ${idx+1}</span><button class="del" title="Remove" data-del="${p.id}">✕</button></div>
<div class="body">
<div class="st">${badge}</div>
${p.text?`<div class="txt" contenteditable="true" data-edit="${p.id}" spellcheck="true">${esc(p.text)}</div>`:'<div class="txt" style="color:#5c6675">No text yet — Read this page.</div>'}
<div class="row">
<button class="btn sm" data-read="${p.id}">${p.text?"Re-read":"Read"}</button>
<button class="btn sm" data-verify="${p.id}" ${(p.text&&state.cfg.engine==="cloud")?"":"disabled"} title="${state.cfg.engine==="cloud"?"Re-check the reading against the image":"Verify needs the Cloud vision engine"}">Verify</button>
</div>
</div>`;
grid.appendChild(el);
});
grid.querySelectorAll("[data-del]").forEach(b=>b.onclick=()=>{ state.pages=state.pages.filter(p=>p.id!==b.dataset.del); renderPages(); });
grid.querySelectorAll("[data-read]").forEach(b=>b.onclick=()=>readPages([state.pages.find(p=>p.id===b.dataset.read)]));
grid.querySelectorAll("[data-verify]").forEach(b=>b.onclick=()=>verifyPages([state.pages.find(p=>p.id===b.dataset.verify)]));
grid.querySelectorAll("[data-edit]").forEach(d=>d.addEventListener("input",()=>{ const p=state.pages.find(x=>x.id===d.dataset.edit); if(p) p.text=d.innerText; }));
const has=state.pages.length>0;
$("#captureActions").hidden=!has;
$("#pageCount").textContent=state.pages.length+(state.pages.length===1?" page":" pages");
}
async function readPages(pages){
pages=pages.filter(Boolean); if(!pages.length) return;
const eng=state.cfg.engine;
if(eng==="cloud" && (!state.cfg.apiKey||!state.cfg.visionModel)){
toast("Set your API key and a vision model in Settings first.","err"); showTab("settings"); return;
}
if(eng==="chrome" && !chromeAIAvailable()){
toast("Chrome built-in AI isn't available here — use a recent Chrome, or pick another engine in Settings.","err"); showTab("settings"); return;
}
for(const p of pages){
p.status="reading"; renderPages();
try{
let text;
if(eng==="tesseract"){
const {canvas}=await prepareCanvas(p.file,{maxDim:2200,enhance:state.cfg.enhanceImages});
text=await ocrTesseract(canvas);
} else if(eng==="chrome"){
const {canvas}=await prepareCanvas(p.file,{maxDim:1600,enhance:state.cfg.enhanceImages});
text=await ocrChromeAI(canvas);
} else {
const url=await prepareImage(p.file,{maxDim:2200,enhance:state.cfg.enhanceImages});
text=await ocrImage(url);
if(looksRefusal(text)) toast("The model replied instead of transcribing — it may not be vision-capable. Try a different model in Settings.","err");
}
if(looksEmpty(text)){ p.text=text||""; p.status="error"; renderPages();
toast(eng==="cloud" ? "No text detected. Check the photo is in focus and that your model supports images."
: "No text found on this page — on-device engines do best on clear, well-lit pages; Cloud vision handles handwriting better.","err"); continue; }
if(state.cfg.autospellOnImport) text=applyAutospell(text);
p.text=text; p.status="read"; renderPages();
if(state.cfg.autoVerify && eng==="cloud") await verifyPages([p]);
}catch(err){ p.status="error"; renderPages(); toast("Read failed: "+err.message,"err"); }
}
}
async function verifyPages(pages){
pages=pages.filter(p=>p&&p.text); if(!pages.length) return;
for(const p of pages){
const before=p.text; p.status="verifying"; renderPages();
try{
const url=await prepareImage(p.file,{maxDim:2200,enhance:state.cfg.enhanceImages});
const fixed=await verifyImage(url,before);
const changed = fixed.trim()!==before.trim();
p.text=fixed; p.status="verified"; renderPages();
toast(changed?"Verified — corrections applied.":"Verified — reading matched the image.","ok");
}catch(err){ p.status="read"; renderPages(); toast("Verify failed: "+err.message,"err"); }
}
}
$("#readAll").onclick=()=>readPages(state.pages.filter(p=>p.status==="queued"||p.status==="error"||!p.text));
$("#verifyAll").onclick=()=>verifyPages(state.pages.filter(p=>p.text));
$("#clearPages").onclick=async()=>{ if(await confirmModal("Clear pages","Remove all captured pages? This won't touch saved notes.","Clear")){ state.pages=[]; renderPages(); } };
$("#combine").onclick=combineIntoNote;
async function combineIntoNote(){
const withText=state.pages.filter(p=>p.text&&p.text.trim());
if(!withText.length){ toast("Read at least one page first.","err"); return; }
const pageMark=(n)=>"<!"+"-- page "+n+" --"+">";
const content=withText.map((p,i)=> state.cfg.pageDividers?`${pageMark(i+1)}\n\n${p.text.trim()}`:p.text.trim()).join("\n\n");
const note={id:uid("n"),title:"Captured note — "+fmtDate(Date.now()),content,tags:["ocr"],createdAt:Date.now(),updatedAt:Date.now()};
await saveNote(note);
state.pages=[]; renderPages();
toast("Note saved from "+withText.length+(withText.length===1?" page.":" pages."),"ok");
await openNote(note.id);
if(state.cfg.autoFormat){ await runAutoFormat(note.id); }
}
// The AI layer: turns raw recognized text into standardized Markdown + YAML. Recognition already happened (local or cloud).
async function runAutoFormat(noteId){
if(!canFormat()){ toast("Auto-format is on, but no text model is set — add one in Settings, or use a browser with built-in AI.","err"); return; }
const note = state.note && state.note.id===noteId ? state.note : await getNote(noteId);
if(!note||!note.content.trim()) return;
const btn=$("#aiClean"); const label=btn?btn.innerHTML:"";
if(btn){ btn.disabled=true; btn.innerHTML='<span class="spin"></span> Formatting…'; }
toast("Auto-formatting with AI…");
try{
const cleaned=await cleanupNote(note.content);
note.content=cleaned; applyFrontmatterMeta(note,cleaned);
await saveNote(note); refreshEditorFields(note);
toast("Formatted and standardised.","ok");
}catch(err){ toast("Auto-format failed: "+err.message,"err"); }
finally{ if(btn){ btn.disabled=false; btn.innerHTML=label; } }
}
function applyFrontmatterMeta(note,md){
const t=md.match(/title:\s*"?([^"\n]+?)"?\s*$/im); if(t) note.title=t[1].trim();
const g=md.match(/tags:\s*\[([^\]]*)\]/i); if(g) note.tags=g[1].split(",").map(s=>s.trim().replace(/^["']|["']$/g,"")).filter(Boolean);
}
function refreshEditorFields(note){
if(!state.note||state.note.id!==note.id) return;
$("#edTitle").value=note.title||""; $("#edSource").value=note.content||"";
renderTags(); renderPreview(); markClean();
}
/* ============================ library ============================ */
function renderLibrary(){
const q=($("#libSearch").value||"").toLowerCase().trim();
const grid=$("#noteGrid");
let items=state.index.slice().sort((a,b)=>b.updatedAt-a.updatedAt);
if(q) items=items.filter(n=>(n.title+" "+(n.tags||[]).join(" ")+" "+(n.preview||"")).toLowerCase().includes(q));
if(!items.length){
grid.innerHTML=`<div class="empty" style="grid-column:1/-1">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#8b96a6" stroke-width="1.5"><path d="M4 4h11l5 5v11a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Z"/><path d="M14 4v6h6"/></svg>
<div>${q?"No notes match that search.":"No notes yet — capture one from the Capture tab."}</div></div>`;
return;
}
grid.innerHTML="";
items.forEach(n=>{
const el=document.createElement("div"); el.className="note"; el.tabIndex=0;
el.innerHTML=`<h3>${esc(n.title||"Untitled")}</h3>
<div class="pv">${esc(n.preview||"")||"<em>empty</em>"}</div>
<div class="meta">${(n.tags||[]).slice(0,4).map(t=>`<span class="tag">${esc(t)}</span>`).join("")}
<span style="margin-left:auto">${n.words||0} words · ${fmtDate(n.updatedAt)}</span></div>`;
el.onclick=()=>openNote(n.id);
el.onkeydown=e=>{ if(e.key==="Enter") openNote(n.id); };
grid.appendChild(el);
});
}
$("#libSearch").addEventListener("input",renderLibrary);
$("#newNote").onclick=async()=>{
const note={id:uid("n"),title:"Untitled note",content:"",tags:[],createdAt:Date.now(),updatedAt:Date.now()};
await saveNote(note); openNote(note.id);
};
$("#exportCsv").onclick=async()=>{
if(!state.index.length){ toast("No notes to export.","err"); return; }
const rows=[["Title","Tags","Created","Updated","Words","Content"]];
for(const meta of state.index){ const n=await getNote(meta.id); if(!n) continue;
rows.push([n.title||"Untitled",(n.tags||[]).join("; "),new Date(n.createdAt).toISOString(),new Date(n.updatedAt).toISOString(),String(countWords(n.content)),n.content||""]); }
try{ if(hasVibes&&vibes.exportData){ vibes.exportData(rows,{filename:"inkwell-notes.csv"}); toast("Exported "+(rows.length-1)+" notes.","ok"); }
else { downloadCsvFallback(rows); } }catch(err){ toast("Export failed: "+err.message,"err"); }
};
function downloadCsvFallback(rows){
const csv=rows.map(r=>r.map(c=>{ c=String(c); return /[",\n]/.test(c)?'"'+c.replace(/"/g,'""')+'"':c; }).join(",")).join("\n");
const a=document.createElement("a"); a.href=URL.createObjectURL(new Blob([csv],{type:"text/csv"})); a.download="inkwell-notes.csv"; a.click(); URL.revokeObjectURL(a.href);
}
/* ============================ editor ============================ */
let saveTimer=null;
function markDirty(){ state.dirty=true; const s=$("#saveState"); s.classList.add("dirty"); s.childNodes[1].textContent=" Unsaved…"; clearTimeout(saveTimer); saveTimer=setTimeout(commitNote,900); }
function markClean(){ state.dirty=false; const s=$("#saveState"); s.classList.remove("dirty"); s.childNodes[1].textContent=" Saved"; }
async function openNote(id){
const n=await getNote(id); if(!n){ toast("Note not found.","err"); return; }
state.note=n; showTab_editor();
$("#edTitle").value=n.title||"";
$("#edSource").value=n.content||"";
renderTags(); renderPreview(); markClean(); setEdMode(state.edMode);
}
function showTab_editor(){ $$(".tab").forEach(t=>t.classList.remove("on")); $$(".view").forEach(v=>v.classList.remove("on")); $("#v-editor").classList.add("on"); window.scrollTo({top:0}); }
$("#backLib").onclick=async()=>{ if(state.dirty) await commitNote(); showTab("library"); };
$("#edTitle").addEventListener("input",()=>{ state.note.title=$("#edTitle").value; markDirty(); });
$("#edSource").addEventListener("input",()=>{ state.note.content=$("#edSource").value; renderPreview(); markDirty(); });
function renderPreview(){ $("#preview").innerHTML=renderMarkdown(toMarkdownFile(state.note)); }
function renderTags(){
const bar=$("#tagbar"); bar.querySelectorAll(".tag").forEach(t=>t.remove());
(state.note.tags||[]).forEach(t=>{
const s=document.createElement("span"); s.className="tag rm"; s.textContent=t; s.title="Remove tag";
s.onclick=()=>{ state.note.tags=state.note.tags.filter(x=>x!==t); renderTags(); markDirty(); };
bar.insertBefore(s,$("#tagInput"));
});
}
$("#tagInput").addEventListener("keydown",e=>{
if(e.key==="Enter"||e.key===","){ e.preventDefault(); const v=e.target.value.trim().replace(/,$/,""); if(v){ state.note.tags=state.note.tags||[]; if(!state.note.tags.includes(v)) state.note.tags.push(v); e.target.value=""; renderTags(); markDirty(); } }
if(e.key==="Backspace"&&!e.target.value&&state.note.tags&&state.note.tags.length){ state.note.tags.pop(); renderTags(); markDirty(); }
});
async function commitNote(){ if(!state.note) return; await saveNote(state.note); markClean(); }
$("#edSeg").addEventListener("click",e=>{ const b=e.target.closest("button"); if(b) setEdMode(b.dataset.mode); });
function setEdMode(mode){
state.edMode=mode;
$$("#edSeg button").forEach(b=>b.classList.toggle("on",b.dataset.mode===mode));
const panes=$("#panes");
$("#paneSrc").style.display = mode==="doc" ? "none":"block";
$("#paneDoc").style.display = mode==="src" ? "none":"block";
panes.style.gridTemplateColumns = mode==="split" ? "1fr 1fr":"1fr";
}
$("#aiClean").onclick=async()=>{
if(!state.note.content.trim()){ toast("Nothing to clean up yet.","err"); return; }
if(!canFormat()){ toast("Set an API key + text model in Settings, or use a browser with built-in AI.","err"); showTab("settings"); return; }
const btn=$("#aiClean"); const label=btn.innerHTML; btn.disabled=true; btn.innerHTML='<span class="spin"></span> Cleaning…';
try{
const cleaned=await cleanupNote(state.note.content);
state.note.content=cleaned; applyFrontmatterMeta(state.note,cleaned);
refreshEditorFields(state.note); markDirty(); await commitNote();
toast("Cleaned up and standardised.","ok");
}catch(err){ toast("Clean-up failed: "+err.message,"err"); }
finally{ btn.disabled=false; btn.innerHTML=label; }
};
$("#quickSpell").onclick=()=>{ state.note.content=applyAutospell(state.note.content); $("#edSource").value=state.note.content; renderPreview(); markDirty(); toast("Quick spell-fix applied.","ok"); };
$("#copyForAI").onclick=async()=>{
const payload=state.cfg.cleanupPrompt+"\n\n---\n\n"+(state.note.content||"");
await copyText(payload,"Prompt + note copied — paste into any AI.");
};
$("#copyMd").onclick=()=>copyText(toMarkdownFile(state.note),"Markdown copied.");
$("#dlMd").onclick=()=>{
const a=document.createElement("a"); const safe=(state.note.title||"note").replace(/[^\w\-]+/g,"-").slice(0,50)||"note";
a.href=URL.createObjectURL(new Blob([toMarkdownFile(state.note)],{type:"text/markdown"})); a.download=safe+".md"; a.click(); URL.revokeObjectURL(a.href);
toast("Downloaded "+safe+".md","ok");
};
$("#savePdf").onclick=()=>printNote(state.note);
$("#delNote").onclick=async()=>{
if(await confirmModal("Delete note","This permanently deletes the note for you and the agent. Can't be undone.","Delete")){
await removeNote(state.note.id); state.note=null; showTab("library"); toast("Note deleted.");
}
};
async function copyText(text,ok){
try{ await navigator.clipboard.writeText(text); toast(ok,"ok"); }
catch(e){ const ta=document.createElement("textarea"); ta.value=text; ta.style.position="fixed"; ta.style.opacity="0"; document.body.appendChild(ta); ta.select();
try{ document.execCommand("copy"); toast(ok,"ok"); }catch(_){ toast("Copy blocked by browser.","err"); } ta.remove(); }
}
function printNote(note){
// Build the print document via DOM instead of a literal HTML-document string, so no
// document-ending tags live in this source for an HTML post-processor to trip over.
const css=[
"body{font-family:Georgia,serif;max-width:720px;margin:36px auto;padding:0 26px;color:#1c1a15;line-height:1.65}",
"h1,h2,h3,h4{font-family:Arial,Helvetica,sans-serif;letter-spacing:-.3px;line-height:1.2}",
".fm{font-family:monospace;font-size:11px;color:#777;border-bottom:1px solid #ddd;padding-bottom:8px;margin-bottom:18px;white-space:pre-wrap}",
"code{font-family:monospace;background:#f0efe9;padding:1px 4px;border-radius:3px}",
"pre{background:#f5f4ee;padding:12px;border-radius:6px;overflow:auto}",
"blockquote{border-left:3px solid #d8a53a;margin:0;padding-left:14px;color:#555;font-style:italic}",
"hr{border:none;border-top:1px solid #ddd;margin:20px 0}a{color:#0f9184}",
"@media print{body{margin:0}}"
].join("");
const iframe=document.createElement("iframe");
Object.assign(iframe.style,{position:"fixed",right:"0",bottom:"0",width:"0",height:"0",border:"0"});
document.body.appendChild(iframe);
const doc=iframe.contentWindow.document;
const st=doc.createElement("style"); st.textContent=css; doc.head.appendChild(st);
doc.title=note.title||"Note";
doc.body.innerHTML=renderMarkdown(toMarkdownFile(note));
setTimeout(()=>{ try{ iframe.contentWindow.focus(); iframe.contentWindow.print(); }catch(e){ toast("Couldn't open print dialog.","err"); }
setTimeout(()=>iframe.remove(),1500); },350);
toast("Opening print dialog — choose “Save as PDF”.","ok");
}
/* ============================ settings ============================ */
async function saveConfig(){ await store.save("config",state.cfg); }
function fillSettings(){
const c=state.cfg;
$("#cfgProvider").value=c.provider; $("#cfgBase").value=c.baseUrl; $("#cfgKey").value=c.apiKey;
$("#cfgVision").value=c.visionModel; $("#cfgText").value=c.textModel;
$("#cfgOcr").value=c.ocrPrompt; $("#cfgClean").value=c.cleanupPrompt;
$$('.card .chip[data-opt]').forEach(el=>el.classList.toggle("on",!!c[el.dataset.opt]));
$("#cfgEngine").value=c.engine||"cloud";
updateEngineNote();
updateProviderHint();
updateStorageInfo();
$("#agentBanner").style.display=hasVibes?"flex":"none";
}
async function updateEngineNote(){
const e=state.cfg.engine; let msg="";
if(e==="tesseract"){
msg="Tesseract.js downloads a small OCR model to your browser on first use, then reads entirely on-device — no key, works offline afterwards, runs in any browser. Great on clear printed pages; weaker on cursive.";
} else if(e==="chrome"){
const st=await chromeAIStatus();
msg = st==="unsupported" ? "This browser doesn't expose Chrome's built-in AI (needs a recent Chrome with Gemini Nano). Reads will fall back with an error — keep Cloud vision as a backup."
: st==="available" ? "Chrome's built-in AI (Gemini Nano) is ready — reads and formats on-device, no key, private and offline. It's a small model, so use Cloud vision for tough handwriting."
: "Chrome's built-in AI needs a one-time on-device model download (large, browser-managed). It'll fetch on your first read. Small model — best for clean text.";
} else {
msg="Reads with your configured vision model — highest quality, including handwriting. Images are sent to the provider (via the proxy) to be read.";
}
const el=$("#engineNote"); if(el) el.textContent=msg;
}
$("#cfgEngine").onchange=async e=>{ state.cfg.engine=e.target.value; await updateEngineNote(); await saveConfig(); };
function updateProviderHint(){
const v=$("#cfgProvider").value, el=$("#providerHint"); if(!el) return;
const links={
gemini:'<b>Free tier</b>: multimodal (reads handwriting), ~1,500 reads/day, no credit card. <a href="https://aistudio.google.com/apikey" target="_blank" rel="noopener">Get a free key -></a><br><span style="color:var(--muted-2)">Note: free-tier prompts may be used to improve Google\'s models — avoid confidential notes.</span>',
groq:'Free tier with vision-capable models. <a href="https://console.groq.com/keys" target="_blank" rel="noopener">Get a free key -></a>',
openrouter:'Has some free models (look for ":free"). <a href="https://openrouter.ai/keys" target="_blank" rel="noopener">Get a key -></a>'
};
if(links[v]){ el.innerHTML=links[v]; el.style.display="block"; } else { el.style.display="none"; }
}
$("#cfgProvider").onchange=e=>{
const v=e.target.value, p=PROVIDERS[v]; state.cfg.provider=v;
if(p&&p.base){ state.cfg.baseUrl=p.base; $("#cfgBase").value=p.base; }
if(v==="gemini"){ if(!$("#cfgVision").value.trim()) $("#cfgVision").value="gemini-2.5-flash"; if(!$("#cfgText").value.trim()) $("#cfgText").value="gemini-2.5-flash"; }
updateProviderHint();
};
$$('.card .chip[data-opt]').forEach(el=>el.addEventListener("click",async()=>{ const k=el.dataset.opt; state.cfg[k]=!state.cfg[k]; el.classList.toggle("on",state.cfg[k]); await saveConfig(); syncOptChips(); }));
$("#cfgSave").onclick=async()=>{
state.cfg.provider=$("#cfgProvider").value; state.cfg.baseUrl=$("#cfgBase").value.trim();
state.cfg.apiKey=$("#cfgKey").value.trim(); state.cfg.visionModel=$("#cfgVision").value.trim(); state.cfg.textModel=$("#cfgText").value.trim();
await saveConfig(); toast("Settings saved.","ok");
};
$("#cfgSavePrompts").onclick=async()=>{ state.cfg.ocrPrompt=$("#cfgOcr").value.trim(); state.cfg.cleanupPrompt=$("#cfgClean").value.trim()||DEFAULT_CLEAN; await saveConfig(); toast("Prompts saved.","ok"); };
$("#cfgTest").onclick=async()=>{
const out=$("#testOut"); out.textContent="Testing…";
state.cfg.baseUrl=$("#cfgBase").value.trim(); state.cfg.apiKey=$("#cfgKey").value.trim(); state.cfg.textModel=$("#cfgText").value.trim(); await saveConfig();
try{ const r=await chat([{role:"user",content:"Reply with the single word: ok"}],{model:state.cfg.textModel||state.cfg.visionModel,maxTokens:5}); out.textContent="✓ Connected — "+r.trim().slice(0,20); out.style.color="var(--teal)"; }
catch(err){ out.textContent="✕ "+err.message; out.style.color="var(--red)"; }
};
async function updateStorageInfo(){
let bytes=0; for(const m of state.index){ const n=await getNote(m.id); if(n) bytes+=JSON.stringify(n).length; }
$("#storageInfo").textContent=`${state.index.length} note${state.index.length===1?"":"s"} · ~${(bytes/1024).toFixed(1)} KB used${hasVibes?"":" (in-memory preview — connect on itjustvibes.com to persist)"}`;
}
$("#wipe").onclick=async()=>{
if(await confirmModal("Delete all notes","This removes every saved note for you and the agent. This cannot be undone.","Delete everything")){
for(const m of state.index.slice()) await removeNote(m.id);
toast("All notes deleted."); updateStorageInfo(); renderLibrary();
}
};
/* ============================ boot ============================ */
async function init(){
try{ state.cfg=Object.assign({},DEFAULT_CFG,(await store.load("config"))||{}); }catch(e){}
if(!["cloud","tesseract","chrome"].includes(state.cfg.engine)) state.cfg.engine="cloud";
await loadIndex();
syncOptChips();
$$("#modeSeg button").forEach(x=>x.classList.toggle("on",x.dataset.mode===state.cfg.captureMode));
renderPages();
showTab("capture");
if(!hasVibes) console.info("[InkWell] Running without the Vibes SDK — using in-memory storage for preview.");
}
if(hasVibes && vibes.onReady) vibes.onReady(init); else init();
</script>
</body>
</html>
```
[REQUESTED CHANGES]
(no specific request — apply your best judgment)
--- HOW TO RESPOND (READ FIRST) ---
Before writing any code, follow this exact process:
1. **ANALYZE** the widget source code provided above and identify:
a. Which Vibes SDK features it already uses (vibes.save, vibes.load, vibes.shared.join, etc.)
b. Which SDK features would genuinely benefit THIS specific widget — tailored to what it does, not a dump of everything available.
2. **PRESENT A NUMBERED LIST** covering:
- SDK features currently active in this widget
- New SDK features that would concretely improve this widget (be specific: why this widget, what it enables)
3. **WAIT** — do not write any code yet. Reply with your analysis and numbered list, then stop and ask the user which numbered items they want.
4. **IMPLEMENT ONLY** the items the user confirms, plus any explicit change they requested. Do not add unrequested features.
**IMPORTANT — Shared state room names:**
If you add vibes.shared.join(), do NOT use a hardcoded string literal as the room name (e.g. vibes.shared.join("lobby")) unless the user explicitly wants ALL viewers to share one single global state. A hardcoded room name means every person who visits this widget reads and writes the same shared state — it is a global room. For per-user or per-session isolation, derive the room name from a variable (e.g. a user ID, session token, or random value). When in doubt, use vibes.save/vibes.load for per-user persistence instead.
--- VIBES SDK CONTEXT ---
## Vibes SDK Reference
You are building an HTML widget for It Just Vibes (itjustvibes.com). The Vibes SDK is auto-injected — do NOT add a script tag. Just use `window.vibes` (or just `vibes`).
### Setup
Wrap your startup code in `vibes.onReady`:
```js
vibes.onReady(async () => {
const saved = await vibes.load("myKey");
// your widget logic here
});
```
### State (Per-User Persistence)
Every user gets their own isolated state per widget. All methods return Promises.
| Method | Description |
|--------|-------------|
| `await vibes.save(key, value)` | Save JSON-serializable data |
| `await vibes.load(key)` | Load saved data (returns `null` if not found) |
| `await vibes.delete(key)` | Delete a saved key |
| `await vibes.listKeys()` | Get array of all saved key names |
**Key rules:**
- Keys are strings, max 64 characters, alphanumeric + dashes/underscores
- Values must be JSON-serializable (objects, arrays, strings, numbers, booleans)
- Max 100KB per value, 500KB per widget per user, 5MB per user total
- Max 100 keys per widget per user
### Fetch Proxy
Use direct `fetch()` for APIs with permissive CORS headers (`Access-Control-Allow-Origin: *`). Use `vibes.fetch` for APIs without CORS headers (the proxy handles cross-origin requests):
```js
const resp = await vibes.fetch("https://api.example.com/data", {
method: "GET", // GET, POST, PUT, DELETE
headers: {}, // optional headers
body: null, // optional body (string)
timeout: null // optional timeout in ms
});
const data = await resp.json(); // or resp.text()
console.log(resp.status, resp.ok);
```
### Multiplayer (Shared State)
Real-time shared state across all users viewing the same widget.
```js
// Join a room (call once at startup)
await vibes.shared.join("lobby", { persistent: true });
// Set shared state (broadcasts to all users)
await vibes.shared.set("score", { player1: 10, player2: 7 });
// Read shared state (synchronous, returns last known value)
const score = vibes.shared.get("score");
// Listen for changes to a specific key
vibes.shared.onChange("score", (newValue) => {
console.log("Score updated:", newValue);
});
// Listen for any shared state change
vibes.shared.onAny((key, value) => {
console.log(key, "changed to", value);
});
// Get number of connected users
const count = await vibes.shared.getUserCount();
// Leave room
vibes.shared.leave();
// Clear all shared state for this room
await vibes.shared.clear();
```
**Shared state options:**
- `{ persistent: true }` — state survives page reloads (stored server-side)
- Default room name is "__default__" if omitted
### Agent-Accessible State (vibes.ai.*)
State written with the standard `vibes.save` / `vibes.load` methods is private to each user and is **NOT readable by AI agents**. To share state with an AI agent (via the MCP connector), use the `vibes.ai` sub-namespace:
| Method | Description |
|--------|-------------|
| `await vibes.ai.setState(key, value)` | Write agent-readable state. Key is stored internally as `ai/<key>`. |
| `await vibes.ai.getState(key)` | Read agent-readable state. Returns `null` if key absent. |
| `await vibes.ai.listKeys()` | List all agent-readable keys (without the `ai/` prefix). |
**Important rules:**
- Regular `vibes.save()` / `vibes.load()` is **NOT agent-accessible** — use `vibes.ai.*` for state you want agents to read.
- The widget **owner** must enable agent access in **Manage → Agent tab** before any agent can read or write `vibes.ai.*` state.
- Keys are auto-prefixed to `ai/` internally; you supply just the short key (e.g. `'context'`).
```js
vibes.onReady(async () => {
// Write state an AI agent can later read
await vibes.ai.setState('context', { currentLevel: 3, score: 1500 });
// Read it back (same auto-prefix applies)
const ctx = await vibes.ai.getState('context');
// List all agent-accessible keys for this widget
const keys = await vibes.ai.listKeys(); // e.g. ['context']
});
```
### Rules
1. **Do NOT use localStorage, sessionStorage, or window.storage** — they are blocked or undefined in the sandbox. Use `vibes.save`/`vibes.load` instead.
2. **Do NOT add a script tag to import the SDK** — it is auto-injected.
3. **Wrap startup code in `vibes.onReady()`** — the SDK may not be ready immediately.
4. **Await all SDK calls** — every method (except `vibes.shared.get`) returns a Promise.
5. **External JS libraries are allowed and encouraged** — load them via a `<script src>` tag from a reputable CDN (jsDelivr, unpkg, or cdnjs) with a pinned version, or inline the library source. The only exception: do NOT add a script tag for the Vibes SDK itself — it is auto-injected (see Rule 2).
6. **Keep total code under 100KB** — that is the default widget size limit (your account limit may be higher).
### Rate Limits
| Operation | Limit |
|-----------|-------|
| Writes (save + delete combined) | 30/min |
| Reads (load) | 60/min |
| List keys | 30/min |
| Fetch proxy | Rate limited per widget |
### Error Handling
All SDK methods can reject. Wrap in try/catch:
```js
try {
await vibes.save("key", value);
} catch (err) {
console.error("Save failed:", err.message);
}
```
Fetch proxy errors include `err.code`: `"RATE_LIMITED"`, `"BLOCKED"`, `"FETCH_ERROR"`.
### Data Export
Export rows of data as CSV or Excel directly from your widget.
```js
// Register dataset for the platform's "Export data" button
// AND optionally trigger an immediate download
vibes.exportData(rows, { filename: 'results.csv' })
// rows: Array of arrays (each inner array is one row; first row = headers)
// options.filename: sets the download filename and format (csv or xlsx)
// options.directDownload: false to only register without downloading (default: true)
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `filename` | string | `'data.csv'` | Download filename; extension determines format (`.csv` or `.xlsx`) |
| `directDownload` | boolean | `true` | Trigger immediate browser download in addition to registering the dataset |
**Notes:**
- First call registers the dataset so the widget chrome shows an "Export data" button
- Use `.csv` extension for CSV, `.xlsx` for Excel
- CSV injection safety is automatic (formula-starting cells prefixed with `'`)
- Data stays in the browser — no server round-trip
--- FORK & RESUBMIT INSTRUCTION ---
Return the complete, self-contained HTML file with all changes applied.
It should be ready to paste into itjustvibes.com/submit to create a new fork.
Include this comment at the top of the output:
/* Forked from: https://itjustvibes.com/Owner/inkwell-ocr-note-capture */