// lilt hub UI. Renders every page from the state main.js pushes over window.lilt.(() => { 'use strict'; const $ = (s, r = document) => r.querySelector(s); const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); const get = (o, p) => p.split('.').reduce((a, k) => a == null ? a : a[k], o); const wc = t => (String(t || '').match(/\S+/g) || []).length; // Icons (24px stroke paths, drawn in the lucide style). const I = { mic: '<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><path d="M12 19v3"/>', rec: '<circle cx="12" cy="12" r="9.5"/><circle cx="12" cy="12" r="4" fill="currentColor"/>', chart: '<path d="M3 3v16a2 2 0 0 0 2 2h16"/><path d="M8 17v-5M13 17V8M18 17v-3"/>', book: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9h1M10 13h4M10 17h4"/>', scissors: '<circle cx="6" cy="6" r="3"/><path d="M8.12 8.12 12 12M20 4 8.12 15.88"/><circle cx="6" cy="18" r="3"/><path d="M14.8 14.8 20 20"/>', type: '<path d="M3 6V4h11v2M8.5 4v15M6 19h5"/><path d="M14 11h7M17.5 11v8M16 19h3"/>', wand: '<path d="m21.64 3.64-1.28-1.28a1.21 1.21 0 0 0-1.72 0L2.36 18.64a1.21 1.21 0 0 0 0 1.72l1.28 1.28a1.2 1.2 0 0 0 1.72 0L21.64 5.36a1.2 1.2 0 0 0 0-1.72"/><path d="m14 7 3 3M5 6v4M19 14v4M10 2v2M7 8H3M21 16h-4M11 3H9"/>', pad: '<path d="M13.4 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7.4"/><path d="M2 6h4M2 10h4M2 14h4M2 18h4"/><path d="M21.38 5.63a1 1 0 1 0-3-3l-5 5a2 2 0 0 0-.5.85l-.84 2.87a.5.5 0 0 0 .62.62l2.87-.84a2 2 0 0 0 .85-.5z"/>', users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>', gift: '<rect x="3" y="8" width="18" height="4" rx="1"/><path d="M12 8v13M19 12v7a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-7"/><path d="M7.5 8a2.5 2.5 0 0 1 0-5C10 3 12 8 12 8s2-5 4.5-5a2.5 2.5 0 0 1 0 5"/>', gear: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>', help: '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3M12 17h.01"/>', sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>', moon: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>', monitor: '<rect width="20" height="14" x="2" y="3" rx="2"/><path d="M8 21h8M12 17v4"/>', bell: '<path d="M10.27 21a2 2 0 0 0 3.46 0"/><path d="M3.26 15.33A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.67C19.41 13.96 18 12.5 18 8A6 6 0 0 0 6 8c0 4.5-1.41 5.96-2.74 7.33"/>', user: '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="10" r="3"/><path d="M7 20.66V19a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1.66"/>', sidebar: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/>', search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>', x: '<path d="M18 6 6 18M6 6l12 12"/>', play: '<path d="M7 4.5v15l12-7.5z"/>', copy: '<rect width="13" height="13" x="8" y="8" rx="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>', flag: '<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1zM4 22v-7"/>', more: '<circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/>', sort: '<path d="m21 16-4 4-4-4M17 20V4M3 8l4-4 4 4M7 4v16"/>', refresh: '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>', plus: '<path d="M5 12h14M12 5v14"/>', check: '<path d="M20 6 9 17l-5-5"/>', info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/>', cal: '<rect width="18" height="18" x="3" y="4" rx="2"/><path d="M16 2v4M8 2v4M3 10h18M8 14h.01M12 14h.01M16 14h.01M8 18h.01M12 18h.01M16 18h.01"/>', sliders: '<path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4"/>', laptop: '<path d="M20 16V7a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v9m16 0H4m16 0 1.28 2.55a1 1 0 0 1-.9 1.45H3.62a1 1 0 0 1-.9-1.45L4 16"/>', hash: '<path d="M4 9h16M4 15h16M10 3 8 21M16 3l-2 18"/>', plug: '<path d="M12 22v-5M9 8V2M15 8V2M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"/>', mcp: '<path d="m15 12-8.5 8.5a2.12 2.12 0 0 1-3-3L12 9"/><path d="M17.64 15 22 10.64M20.91 11.7l-1.25-1.25a2.4 2.4 0 0 1 0-3.4l.86-.86a2.4 2.4 0 0 0 0-3.4L17.3 0"/>', card: '<rect width="20" height="14" x="2" y="5" rx="2"/><path d="M2 10h20"/>', shield: '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/>', trash: '<path d="M3 6h18M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>', pencil: '<path d="M21.17 6.81a1 1 0 0 0-3.99-3.99L3.84 16.17a2 2 0 0 0-.5.83l-1.32 4.35a.5.5 0 0 0 .62.62l4.35-1.32a2 2 0 0 0 .83-.5z"/>', back: '<path d="m12 19-7-7 7-7M19 12H5"/>', undo: '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/>', inf: '<path d="M12 12c-2-2.67-4-4-6-4a4 4 0 1 0 0 8c2 0 4-1.33 6-4Zm0 0c2 2.67 4 4 6 4a4 4 0 0 0 0-8c-2 0-4 1.33-6 4Z"/>', bot: '<path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2M20 14h2M15 13v2M9 13v2"/>', bubble: '<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/>', mail: '<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>', msg: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><path d="M13 8H7M17 12H7"/>', share: '<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8M16 6l-4-4-4 4M12 2v13"/>', trend: '<path d="M22 7 13.5 15.5 8.5 10.5 2 17M16 7h6v6"/>', download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/>', stop: '<rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor"/>', folder: '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>', file: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4M10 9H8M16 13H8M16 17H8"/>', cloud: '<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"/>', }; const ic = (n, cls = '') => `<svg class="i ${cls}" viewBox="0 0 24 24">${I[n] || ''}</svg>`; // lilt mark: a cursive l that trails off into a lilting tail and the orange accent dot. const logo = '<svg viewBox="40 34 180 186"><path d="M58 196c18-6 24-40 30-92 4-36 12-56 26-56 12 0 14 18 8 46-8 40-30 76-30 98 0 10 6 14 14 14 20 0 34-34 50-62 8-14 18-22 30-22" fill="none" stroke="currentColor" stroke-width="20" stroke-linecap="round" stroke-linejoin="round"/><circle cx="198" cy="122" r="15" fill="#fc9e40"/></svg>'; // ---------------------------------------------------------------- state let S = null; const ui = { page: 'home', tab: {}, search: {}, searchOpen: {}, sort: {}, settings: null, note: null, meeting: null, dirty: false, side: true, cmdOpen: {}, }; const toast = (msg, ms = 2600) => { const t = $('#toast'); t.textContent = msg; t.classList.remove('hidden'); clearTimeout(toast.t); toast.t = setTimeout(() => t.classList.add('hidden'), ms); }; function applyTheme() { const t = S.settings.theme; const dark = t === 'dark' || (t === 'system' && matchMedia('(prefers-color-scheme: dark)').matches); document.documentElement.dataset.theme = dark ? 'dark' : 'light'; $('#theme-btn').innerHTML = ic(dark ? 'sun' : 'moon'); } matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => S && applyTheme()); // Re-rendering while the user types would eat keystrokes; wait for blur. function editing() { const a = document.activeElement; return a && /^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName) && a.type !== 'checkbox'; } function onState(st) { S = st; if (editing()) { ui.dirty = true; return; } render(); } document.addEventListener('focusout', () => setTimeout(() => { if (ui.dirty && !editing()) { ui.dirty = false; render(); } }, 0)); async function setSetting(path, value) { // optimistic local update so toggles feel instant const keys = path.split('.'); let o = S.settings; keys.slice(0, -1).forEach(k => { o[k] = o[k] && typeof o[k] === 'object' ? o[k] : {}; o = o[k]; }); o[keys.at(-1)] = value; render(); await lilt.set(path, value); } function go(route) { const [page, sub] = route.split('/'); if (page === 'settings') { ui.settings = sub || 'general'; renderSettings(); return; } ui.page = page; ui.note = null; ui.meeting = null; if (page === 'insights' && sub) ui.tab.insights = sub; if (page === 'scratchpad' && sub === 'new') return newNote(); closeMenus(); render(); $('#main').scrollTop = 0; } // ---------------------------------------------------------------- stats const dayKey = ts => { const d = new Date(ts); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; }; const fmtK = n => n >= 1000 ? (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K' : String(n); const fmtTime = ts => new Date(ts).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }).replace(' ', '').toLowerCase(); const fmtDay = ts => { const k = dayKey(ts), now = Date.now(); if (k === dayKey(now)) return 'TODAY'; if (k === dayKey(now - 864e5)) return 'YESTERDAY'; return new Date(ts).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }).toUpperCase(); }; const fmtDur = ms => { const s = Math.round(ms / 1000); return s >= 3600 ? `${Math.floor(s / 3600)}h ${Math.floor(s % 3600 / 60)}m` : `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; }; const TERMS = /^(st|kitty|alacritty|foot|xterm|urxvt|wezterm|ghostty|konsole|gnome-terminal|terminator|code|cursor|zed|windsurf)/i; function category(app = '') { const a = app.toLowerCase(); if (/signal|telegram|discord|whatsapp|element|messages/.test(a)) return 'personal'; if (/slack|teams|zulip|mattermost/.test(a)) return 'work'; if (/thunderbird|geary|evolution|mutt|mail/.test(a)) return 'email'; if (/libreoffice|soffice|obsidian|notion|typora|onlyoffice|abiword|writer/.test(a)) return 'docs'; if (TERMS.test(a)) return 'ai'; return 'other'; } function stats() { const h = S.history.filter(x => !x.dismissed && x.text); const words = h.reduce((a, x) => a + (x.words || wc(x.text)), 0); const timed = h.filter(x => x.ms > 1500); const mins = timed.reduce((a, x) => a + x.ms, 0) / 60000; const wpm = mins ? Math.round(timed.reduce((a, x) => a + (x.words || wc(x.text)), 0) / mins) : 0; const perDay = {}; h.forEach(x => { const k = dayKey(x.ts); perDay[k] = (perDay[k] || 0) + (x.words || wc(x.text)); }); let streak = 0, t = Date.now(); if (!perDay[dayKey(t)]) t -= 864e5; while (perDay[dayKey(t)]) { streak++; t -= 864e5; } let longest = 0, run = 0, prev = null; Object.keys(perDay).sort().forEach(k => { const d = new Date(k + 'T12:00').getTime(); run = prev && Math.round((d - prev) / 864e5) === 1 ? run + 1 : 1; longest = Math.max(longest, run); prev = d; }); const now = new Date(), m0 = new Date(now.getFullYear(), now.getMonth(), 1).getTime(), m1 = new Date(now.getFullYear(), now.getMonth() - 1, 1).getTime(); const thisMonth = h.filter(x => x.ts >= m0).reduce((a, x) => a + (x.words || wc(x.text)), 0); const before = words - thisMonth; const growth = before ? Math.round(thisMonth / before * 100) : (thisMonth ? 100 : 0); let corrected = 0; h.forEach(x => { if (!x.raw || x.raw === x.text) return; const bag = {}; (x.raw.toLowerCase().match(/[\w']+/g) || []).forEach(w => bag[w] = (bag[w] || 0) + 1); (x.text.toLowerCase().match(/[\w']+/g) || []).forEach(w => { if (bag[w]) bag[w]--; else corrected++; }); }); const dictWords = S.dictionary.map(d => (d.to || d.word).toLowerCase()); const dictFixes = h.reduce((a, x) => a + dictWords.filter(w => w && x.text.toLowerCase().includes(w)).length, 0); const cats = { other: 0, ai: 0, docs: 0, personal: 0, email: 0, work: 0 }; h.forEach(x => cats[category(x.app)]++); const apps = new Set(h.map(x => (x.app || '').toLowerCase()).filter(Boolean)).size; return { h, words, wpm, streak, longest, perDay, growth, corrected, dictFixes, cats, apps, m1 }; } // ---------------------------------------------------------------- shell function render() { if (!S) return; applyTheme(); $('#app').classList.toggle('side-off', !ui.side); renderSide(); const pages = { home, notetaker, assistant: assistantPage, insights, dictionary, snippets, style, transforms, scratchpad }; $('#main').innerHTML = (pages[ui.page] || home)(); if (ui.settings) renderSettings(); } const NAV = [ ['home', 'mic', 'Dictation'], ['notetaker', 'rec', 'Notetaker'], ['assistant', 'bot', 'Assistant'], ['insights', 'chart', 'Insights'], ['dictionary', 'book', 'Dictionary'], ['snippets', 'scissors', 'Snippets'], ['style', 'type', 'Style'], ['transforms', 'wand', 'Transforms'], ['scratchpad', 'pad', 'Scratchpad'], ]; function renderSide() { $('#side').innerHTML = ` <div class="logo">${logo}<span>lilt</span></div> <nav class="nav">${NAV.map(([id, icon, label]) => `<button class="${ui.page === id ? 'on' : ''}" data-go="${id}">${ic(icon)}${label}</button>`).join('')}</nav> <div class="side-fill"></div> <div class="side-links"> <button data-go="settings/general">${ic('gear')}Settings</button> <button data-act="help">${ic('help')}Help</button> </div>`; const tb = document.querySelectorAll('.topbar .icon-btn'); tb[0].innerHTML = ic('sidebar'); tb[2].innerHTML = ic('bell'); tb[3].innerHTML = ic('user'); } const banner = (cls, html, closeKey) => `<div class="banner ${cls}">${closeKey ? `<button class="close" data-act="dismiss" data-key="${closeKey}">${ic('x')}</button>` : ''}${html}</div>`; const tabs = (key, list, tools = '') => { const cur = ui.tab[key] || list[0][0]; return `<div class="tabs">${list.map(([id, label, extra]) => `<button class="${cur === id ? 'on' : ''}" data-tab="${key}:${id}">${label}${extra || ''}</button>`).join('')}<span class="spacer"></span>${tools}</div>`; }; const listTools = key => `<span class="tools"> <button data-act="search" data-key="${key}" title="Search">${ic('search')}</button> <button data-act="sort" data-key="${key}" title="Sort">${ic('sort')}</button> <button data-act="refresh" title="Refresh">${ic('refresh')}</button></span>`; const searchBox = key => ui.searchOpen[key] ? `<input class="search-in" data-search="${key}" placeholder="Search" value="${esc(ui.search[key] || '')}" autofocus>` : ''; const matches = (key, ...fields) => { const q = (ui.search[key] || '').toLowerCase(); return !q || fields.some(f => String(f || '').toLowerCase().includes(q)); }; // ---------------------------------------------------------------- home function home() { const st = stats(), set = S.settings, rt = S.runtime; const items = S.history.filter(x => matches('home', x.text, x.raw)).sort((a, b) => b.ts - a.ts); const groups = []; items.forEach(x => { const d = fmtDay(x.ts); if (!groups.length || groups.at(-1).d !== d) groups.push({ d, xs: [] }); groups.at(-1).xs.push(x); }); const hist = groups.map((g, i) => ` <div class="day"><span>${g.d}</span>${i === 0 ? `<button data-act="search" data-key="home">${ic('search')}</button>` : ''}</div> ${i === 0 ? searchBox('home') : ''} <div class="list hist">${g.xs.map(histRow).join('')}</div>`).join(''); const hint = `Hold <b>${esc(rt.hotkeyLabel || set.hotkey?.label || 'Right Alt')}</b> and speak into any text field. Double-tap it for hands-free.`; const empty = `<div class="day"><span>TODAY</span></div><div class="list"><div class="row"><span class="grow muted">${hint}</span></div></div>`; return `<div class="home"> <div class="home-head"><h1>Welcome back, ${esc(set.firstName || 'there')}</h1></div> <div> ${rt.engine !== 'ready' ? `<div class="list" style="margin-bottom:20px"><div class="row"><span class="status-dot ${rt.engine}"></span><span class="grow">${engineText()}</span><button class="btn sm ghost" data-go="settings/general">Models</button></div></div>` : ''} ${groups.length ? hist : (ui.search.home ? searchBox('home') + '<div class="empty">No matches</div>' : empty)} </div> <div class="home-side"> <div class="stats-card"> <div class="nums"> <div class="num"><b>${fmtK(st.words)}</b>total words</div> <div class="num"><b>${st.wpm}</b>wpm</div> <div class="num" style="margin:0"><b>${st.streak}</b>day streak</div> </div> </div> </div></div>`; } function engineText() { const rt = S.runtime; if (rt.engine === 'loading') return 'Loading the speech model onto the GPU…'; if (rt.engine === 'error') return `Speech engine error: ${esc(rt.engineError || 'unknown')}`; return 'Speech engine is off. Pick a downloaded model in Settings.'; } function histRow(x) { if (x.dismissed) return `<div class="row"><span class="time">${fmtTime(x.ts)}</span><span class="grow text dim">This transcription was dismissed. <button data-hist="recover" data-id="${x.id}">Recover</button></span></div>`; const text = x.text ? esc(x.text) : '<span class="muted">No speech detected</span>'; return `<div class="row" data-row="${x.id}"><span class="time">${fmtTime(x.ts)}</span> <span class="grow text">${text}${x.flagged ? `<span class="tag">${ic('flag')}</span>` : ''}</span> <span class="acts"> ${x.audio ? `<button data-act="play" data-id="${x.id}" title="Play">${ic('play')}</button>` : ''} <button data-act="copy" data-id="${x.id}" title="Copy">${ic('copy')}</button> <button data-hist="flag" data-id="${x.id}" title="${x.flagged ? 'Unflag' : 'Send feedback'}">${ic('flag')}</button> <button data-act="hist-more" data-id="${x.id}" title="More">${ic('more')}</button> </span></div>`; } // ---------------------------------------------------------------- notetaker function notetaker() { const rt = S.runtime; if (ui.meeting) return meetingView(); const on = rt.notetaker; const today = S.meetings.filter(m => dayKey(m.ts) === dayKey(Date.now())); const list = S.meetings.filter(m => matches('notetaker', m.title, m.summary, m.transcript)).sort((a, b) => b.ts - a.ts); let body; body = list.length ? `<div class="list">${list.map(m => `<div class="row" data-meeting="${m.id}" style="cursor:pointer"> <span class="doc-dot">${ic('file')}</span><span class="grow"><b style="font-weight:500">${esc(m.title || 'Untitled meeting')}</b> <div class="muted" style="font-size:13px">${new Date(m.ts).toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' })} · ${fmtDur(m.ms || 0)}</div></span></div>`).join('')}</div>` : `<div class="list"><div class="row"><span class="doc-dot">${ic('file')}</span><span class="grow" style="font-weight:500">Your meetings will appear here</span></div></div>`; return `<div class="page narrow"> <div class="page-head"><h1>Notetaker</h1><div class="head-actions"> <button class="icon-btn" data-go="settings/notetaker" title="Notetaker settings">${ic('gear')}</button> <button class="btn ghost" data-act="notetaker-${on ? 'stop' : 'start'}">${on ? ic('stop') + 'Stop Notetaker' : '<span class="green-dot"></span>Start Notetaker'}</button> </div></div> <div class="nt-card"><div class="label"><span>TODAY</span><button data-act="refresh" class="muted">${ic('refresh')}</button></div> ${on ? `<div class="live"><span class="rec"></span><span class="grow"><b style="font-weight:500">Recording</b> <span class="muted">microphone and system audio</span></span> <span id="nt-timer" data-start="${on.startedAt}">${fmtDur(Date.now() - on.startedAt)}</span><button class="btn sm" data-act="notetaker-stop">Stop</button></div>` : ''} ${today.length ? today.map(m => `<div class="live" data-meeting="${m.id}" style="cursor:pointer"><span class="grow">${esc(m.title || 'Untitled meeting')}</span><span class="muted">${fmtTime(m.ts)}</span></div>`).join('') : on ? '' : `<div class="nt-empty">${ic('cal')}<h4>No meetings found</h4><p>Start Notetaker during a call for a transcript, summary and action items. Audio stays on this machine.</p> <button class="btn line" data-act="notetaker-start">Start Notetaker</button></div>`} </div> ${tabs('notetaker', [['past', 'Past notes']], `<span class="tools"><button data-act="search" data-key="notetaker">${ic('search')}</button></span>`)} ${searchBox('notetaker')}${body}</div>`; } function meetingView() { const m = S.meetings.find(x => x.id === ui.meeting); if (!m) { ui.meeting = null; return notetaker(); } return `<div class="page narrow"> <div class="page-head"><button class="icon-btn" data-act="meeting-back">${ic('back')}</button><h1>${esc(m.title || 'Untitled meeting')}</h1> <div class="head-actions"><button class="btn ghost sm" data-act="copy-text" data-text="${esc(m.summary || '')}">${ic('copy')}Copy summary</button> <button class="icon-btn" data-act="meeting-delete" data-id="${m.id}" title="Delete">${ic('trash')}</button></div></div> <p class="muted" style="margin-top:-14px">${new Date(m.ts).toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'short' })} · ${fmtDur(m.ms || 0)}</p> ${m.audio ? `<audio controls style="width:100%;margin:6px 0 20px" data-audio="${m.id}"></audio>` : ''} <div class="nt-card md">${m.summary ? md(m.summary) : '<p class="muted">Summary is being written…</p>'}</div> <h3 style="font-size:16px;font-weight:600">Transcript</h3> <div class="muted" style="white-space:pre-wrap;line-height:1.7">${esc(m.transcript || 'Transcribing…')}</div></div>`; } function md(s) { return esc(s).split('\n').map(l => { l = l.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>'); if (/^#{1,3} /.test(l)) return `<h3>${l.replace(/^#+ /, '')}</h3>`; if (/^\s*[-*] /.test(l)) return `<li>${l.replace(/^\s*[-*] (\[[ x]\] )?/, '')}</li>`; return l.trim() ? `<p style="margin:4px 0">${l}</p>` : ''; }).join('').replace(/(<li>.*?<\/li>)+/g, m => `<ul>${m}</ul>`); } // ---------------------------------------------------------------- insights function insights() { const st = stats(), sub = ui.tab.insights || 'usage'; const head = `<div class="page-head"><h1>Insights</h1><button class="icon-btn" data-act="share-insights" title="Copy summary">${ic('share')}</button></div> ${tabs('insights', [['usage', 'Your usage'], ['voice', 'Your voice']])}`; if (sub === 'voice') return `<div class="page">${head}${voiceReport(st)}</div>`; const f = Math.min(st.wpm / 150, 1); const top = st.wpm >= 110 ? '1%' : st.wpm >= 90 ? '5%' : st.wpm >= 75 ? '10%' : st.wpm >= 60 ? '25%' : st.wpm >= 45 ? '50%' : null; const books = [['Dr. Seuss book', 1600], ['short story', 7500], ['novella', 30000], ['The Great Gatsby', 47094], ['Harry Potter book', 76944], ['Lord of the Rings', 481103]]; const [bName, bLen] = books.filter(b => st.words >= b[1]).at(-1) || books[0]; const nBooks = Math.max(1, Math.floor(st.words / bLen)); const total = Object.values(st.cats).reduce((a, b) => a + b, 0) || 1; const rows = [['inf', 'other', 'other tasks'], ['bot', 'ai', 'AI prompts'], ['file', 'docs', 'documents'], ['bubble', 'personal', 'personal messages'], ['mail', 'email', 'emails'], ['msg', 'work', 'work messages']]; return `<div class="page">${head} <div class="ins-grid"> <div class="ins-card"><div class="big">${st.wpm}</div><div class="cap">Words per minute <span title="Words dictated divided by recording time">${ic('info')}</span></div> <div class="gauge"><svg viewBox="0 0 160 92"><path d="M12 84a68 68 0 0 1 136 0" stroke="var(--cell)" stroke-width="14" fill="none" stroke-linecap="round" pathLength="100"/> <path d="M12 84a68 68 0 0 1 136 0" stroke="var(--teal-1)" stroke-width="14" fill="none" stroke-linecap="round" pathLength="100" stroke-dasharray="${f * 100} 100"/></svg> <div>${top ? `Top<b>${top}</b>` : '<b>—</b>'}</div></div></div> <div class="ins-card"><div class="big">${st.corrected + st.dictFixes}</div><div class="cap">Fixes made by lilt</div><hr> <div class="kv"><span>${st.corrected} words corrected</span><span title="Words changed by cleanup and Style">${ic('info')}</span></div> <div class="kv"><span>${st.dictFixes} dictionary fixes</span><span title="Dictations that used your Dictionary">${ic('info')}</span></div></div> <div class="ins-card w2"><div class="ins-headrow"><div class="big">${st.words.toLocaleString()}</div>${st.growth ? `<span class="up">${ic('trend')}${st.growth}% this month</span>` : ''}</div> <div class="cap">Total words dictated</div><hr><div>You've written ${nBooks} ${bName}${nBooks > 1 && !bName.startsWith('The') && !bName.startsWith('Lord') ? 's' : ''}!</div> <div class="split"><span style="flex:1">${ic('monitor')}Desktop</span></div></div> <div class="ins-card w2"><div class="ins-headrow"><span class="ins-title">Desktop usage</span><span class="cap">Total apps used | ${st.apps}</span></div> ${rows.map(([icon, k, label]) => { const p = Math.round(st.cats[k] / total * 100); return `<div class="usage-row">${ic(icon)}<div class="track"><span class="bar" style="width:${Math.max(p * 0.55, 6)}%">${p}%</span>${st.cats[k]} ${label}</div></div>`; }).join('')}</div> <div class="ins-card w2"><div class="ins-headrow"><span class="ins-title">${st.streak} day streak</span><span class="cap">Longest streak | ${st.longest} days</span></div> ${heatmap(st)}</div> </div></div>`; } function heatmap(st) { const weeks = 18, end = new Date(); end.setHours(12, 0, 0, 0); const start = new Date(end); start.setDate(end.getDate() - end.getDay() - (weeks - 1) * 7); const vals = Object.values(st.perDay).sort((a, b) => a - b); const q = p => vals[Math.floor(p * (vals.length - 1))] || 0; const level = v => !v ? '' : v >= q(.75) ? 'l4' : v >= q(.5) ? 'l3' : v >= q(.25) ? 'l2' : 'l1'; let months = '<span></span>', lastM = -1; for (let w = 0; w < weeks; w++) { const d = new Date(start); d.setDate(start.getDate() + w * 7); months += `<span>${d.getMonth() !== lastM ? d.toLocaleDateString('en-US', { month: 'short' }) : ''}</span>`; lastM = d.getMonth(); } const today = dayKey(Date.now()); let cells = ''; ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].forEach((name, r) => { cells += `<span>${name}</span>`; for (let w = 0; w < weeks; w++) { const d = new Date(start); d.setDate(start.getDate() + w * 7 + r); const k = dayKey(d.getTime()); cells += d > end ? '<span></span>' : `<i class="${level(st.perDay[k])}${k === today && st.streak ? ' cur' : ''}" title="${k}: ${st.perDay[k] || 0} words"></i>`; } }); return `<div class="heat">${months}${cells}</div> <div class="legend">More <i style="background:var(--teal-1)"></i><i style="background:var(--teal-2)"></i><i style="background:var(--teal-3)"></i><i style="background:var(--teal-4)"></i> Less <span style="flex:1"></span><i style="border:1.5px solid var(--teal-4)"></i> Current streak</div>`; } function voiceReport(st) { if (!st.h.length) return '<div class="empty">Dictate a few times and your voice profile will show up here.</div>'; const stop = new Set('the a an and or but to of in on at for is it i you we that this be are was with as so my me just like um uh do not have can will your our if what about from they them he she its im dont'.split(' ')); const freq = {}; st.h.forEach(x => (x.text.toLowerCase().match(/[a-z][a-z']+/g) || []).forEach(w => { if (!stop.has(w) && w.length > 2 && !w.includes("'")) freq[w] = (freq[w] || 0) + 1; })); const top = Object.entries(freq).sort((a, b) => b[1] - a[1]).slice(0, 24); const hours = Array(24).fill(0); st.h.forEach(x => hours[new Date(x.ts).getHours()]++); const peak = hours.indexOf(Math.max(...hours)); const apps = {}; st.h.forEach(x => { if (x.app) apps[x.app] = (apps[x.app] || 0) + 1; }); const fav = Object.entries(apps).sort((a, b) => b[1] - a[1])[0]; const longest = st.h.reduce((a, x) => (x.words || 0) > (a.words || 0) ? x : a, st.h[0]); const avg = Math.round(st.words / st.h.length); const hourName = h => new Date(2000, 0, 1, h).toLocaleTimeString('en-US', { hour: 'numeric' }).toLowerCase().replace(' ', ''); return `<div class="ins-grid"> <div class="ins-card"><div class="big">${avg}</div><div class="cap">Words per dictation</div></div> <div class="ins-card"><div class="big">${hourName(peak)}</div><div class="cap">Most active hour</div></div> <div class="ins-card w2"><div class="big">${esc(fav ? fav[0] : '—')}</div><div class="cap">Where you dictate most</div></div> <div class="ins-card w2"><span class="ins-title">Your words</span><div class="words">${top.map(([w, n]) => `<span title="${n} times">${esc(w)}</span>`).join('')}</div></div> <div class="ins-card w2"><span class="ins-title">Longest dictation</span><p class="muted" style="margin:10px 0 0">${longest.words || wc(longest.text)} words · ${new Date(longest.ts).toLocaleDateString()}</p> <p style="margin:8px 0 0;max-height:120px;overflow:hidden">${esc(longest.text)}</p></div></div>`; } // ---------------------------------------------------------------- dictionary + snippets function dictionary() { const sorted = [...S.dictionary].filter(d => matches('dict', d.word, d.to)); if (ui.sort.dict) sorted.sort((a, b) => a.word.localeCompare(b.word)); const rows = sorted.map(d => `<div class="row"><span class="grow entry">${esc(d.word)}${d.to ? `<span class="arrow">→</span>${esc(d.to)}` : ''}${d.auto ? ' ✨' : ''}</span> <span class="acts"><button data-act="dict-edit" data-id="${d.id}" title="Edit">${ic('pencil')}</button><button data-act="remove" data-coll="dictionary" data-id="${d.id}" title="Delete">${ic('trash')}</button></span></div>`).join(''); const examples = ['Samir', 'Sara', 'Karol', 'Spyder'].filter(w => !S.dictionary.some(d => d.word === w)); return `<div class="page narrow"> <div class="page-head"><h1>Dictionary</h1><div class="head-actions"> <button class="btn" data-act="import" data-coll="dictionary">Import</button> <button class="btn" data-act="dict-edit">Add new</button></div></div> ${tabs('dict', [['all', 'All']], listTools('dict'))} ${searchBox('dict')} ${S.settings.dismissed?.dictBanner ? '' : banner('b-dict', `<h2>lilt spells the way <em>you</em> do.</h2><p>Correct a spelling once or add it here manually, so your <b>personal terms, jargon, or uncommon names</b> are spelled right in both dictations and meeting notes.</p> <div class="chips"><button class="chip solid" data-act="dict-edit">Add new word</button>${examples.map(w => `<button class="chip" data-act="dict-quick" data-word="${esc(w)}">${esc(w)}</button>`).join('')}</div>`, 'dictBanner')} ${rows ? `<div class="list">${rows}</div>` : '<div class="empty">No words yet</div>'}</div>`; } function snippets() { const sorted = [...S.snippets].filter(x => matches('snip', x.trigger, x.text)); if (ui.sort.snip) sorted.sort((a, b) => a.trigger.localeCompare(b.trigger)); const rows = sorted.map(x => `<div class="row"><span class="grow entry" style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(x.trigger)}<span class="arrow">→</span>${esc(x.text)}</span> <span class="acts"><button data-act="snip-edit" data-id="${x.id}" title="Edit">${ic('pencil')}</button><button data-act="remove" data-coll="snippets" data-id="${x.id}" title="Delete">${ic('trash')}</button></span></div>`).join(''); const ex = [['“my LinkedIn”', 'https://www.linkedin.com/in/john-doe/'], ['“rewrite prompt”', 'Rewrite this to be more concise...'], ['“intro email”', 'Hey, would love to find some time to chat later...']]; return `<div class="page narrow"> <div class="page-head"><h1>Snippets</h1><div class="head-actions"> <button class="btn" data-act="import" data-coll="snippets">Import</button> <button class="btn" data-act="snip-edit">Add new</button></div></div> ${tabs('snip', [['all', 'All']], listTools('snip'))} ${searchBox('snip')} ${S.settings.dismissed?.snipBanner ? '' : banner('b-snip', `<h2>The stuff <em>you</em> shouldn't have to re-type.</h2><p>Save text you type often — an email, intro, or prompt — then say a word to drop it in instantly.</p> <div class="chip-rows">${ex.map(([a, b]) => `<span class="chip">${a}</span><span class="chip">${b}</span>`).join('')}</div> <button class="btn dark" data-act="snip-edit">Add new snippet</button>`, 'snipBanner')} ${rows ? `<div class="list">${rows}</div>` : '<div class="empty">No snippets yet</div>'}</div>`; } // ---------------------------------------------------------------- style const STYLE_APPS = { personal: 'Signal, Telegram, Discord, WhatsApp, Element', work: 'Slack, Teams, Zulip, Mattermost', email: 'Thunderbird, Geary, Gmail, Proton Mail, Outlook', other: 'everything else: terminals, editors, browsers, notes' }; const STYLE_SAMPLES = { personal: ['Hey, are you free for lunch tomorrow? Let’s do 12 if that works for you.', 'Hey are you free for lunch tomorrow? Let’s do 12 if that works for you', 'hey are you free for lunch tomorrow? let’s do 12 if that works for you'], work: ['Hi team, the release is ready for review. Please take a look before Friday.', 'Hi team, the release is ready for review. Take a look before Friday', 'hi team, release is ready for review. take a look before friday'], email: ['Hi Sarah,\n\nThanks for the update. I’ll review the draft and send notes by Thursday.\n\nBest,\nAda', 'Hi Sarah,\n\nThanks for the update. I’ll look over the draft and send notes by Thursday\n\nAda', 'hi sarah\n\nthanks for the update, i’ll look over the draft and send notes by thursday\n\nada'], other: ['Update the config so the server restarts cleanly after a deploy.', 'Update the config so the server restarts cleanly after a deploy', 'update the config so the server restarts cleanly after a deploy'], }; function style() { const sub = ui.tab.style || 'personal'; const head = `<div class="page narrow"><div class="page-head"><h1>Style</h1></div> ${tabs('style', [['personal', 'Personal messages'], ['work', 'Work messages'], ['email', 'Email'], ['other', 'Other'], ['cleanup', 'Auto cleanup', ' <span class="beta purple">Beta</span>']])}`; if (sub === 'cleanup') { const lv = S.settings.cleanup || 'light'; const cards = [['none', 'None', 'Transcribes exactly what you said, including mistakes', 'hey joey, we still on for coffee or? I think we maybe should leave earlier to make it there in time there might um be traffic. What are you thinking?'], ['light', 'Light', 'Cleans up filler words and grammar', 'Hey Joey, are we still on for coffee? I think we should leave earlier to make it there in time. There might be traffic. What are you thinking?'], ['medium', 'Medium', 'Edits for clarity and conciseness', 'Hey Joey, are we still on for coffee? We should leave earlier; there might be traffic. What do you think?']]; return head + banner('b-clean', `<h2>Auto Cleanup applies to all your dictations</h2><p>Choose the level of cleanup that’s automatically applied every time, across all apps.</p><p>Note your original dictation is never lost. Just go to the three dots next to a recent dictation in the Home tab, then click “Undo AI edit.”</p>`) + `<div class="cards3">${cards.map(([id, t, d, s]) => `<button class="scard ${lv === id ? 'on' : ''}" data-act="set" data-path="cleanup" data-val="${id}"><h3>${t}</h3><p>${d}</p><div class="sample">${s}</div></button>`).join('')}</div></div>`; } const cur = S.settings.styles?.[sub] || 'formal'; const sm = STYLE_SAMPLES[sub]; const cards = [['formal', 'Formal.', 'Caps + Punctuation', sm[0]], ['casual', 'Casual', 'Caps + Less punctuation', sm[1]], ['veryCasual', 'very casual', 'No caps + Less punctuation', sm[2]]]; return head + banner('b-voice', `<h2>Make lilt sound like <em>you</em></h2><p>Set up different writing styles for different apps.</p>`) + `<p class="apps-line">Applies in ${STYLE_APPS[sub]}.</p> <div class="cards3">${cards.map(([id, t, d, s]) => `<button class="scard ${cur === id ? 'on' : ''}" data-act="set" data-path="styles.${sub}" data-val="${id}"><h3>${t}</h3><p>${d}</p><div class="sample" style="white-space:pre-wrap">${s}</div></button>`).join('')}</div></div>`; } // ---------------------------------------------------------------- transforms const DEFAULT_TRANSFORMS = [ { id: 'polish', name: 'Polish', desc: 'Improve clarity and conciseness', key: '1', prompt: 'Rewrite the text to be clearer and more concise. Fix grammar and lilt. Keep the meaning, voice and language. Output only the rewritten text.' }, { id: 'prompt-engineer', name: 'Prompt Engineer', desc: 'Constructs optimal prompts', key: '2', prompt: 'Turn the text into a clear, well-structured prompt for an AI model. State the goal, context, constraints and the expected output format. Output only the prompt.' }, ]; function transforms() { const st = S.settings, tf = S.transforms; const keyBadge = k => `<span class="kbd"><span>Ctrl</span><span>Alt</span><span>${esc(k)}</span></span>`; const dots = [['S', 18, 4], ['G', 108, 4], ['in', 200, 4], ['N', 62, 40], ['T', 152, 40], ['Z', 18, 76], ['M', 108, 76], ['C', 200, 76]]; return `<div class="page narrow"> <div class="page-head"><h1>Transforms <span class="beta">Beta</span></h1><div class="head-actions"> <span class="optin">Opt in <span title="Enables Ctrl+Alt+number on selected text in any app">${ic('info')}</span><button class="toggle ${st.transformsOptIn ? 'on' : ''}" data-toggle="transformsOptIn"></button></span> <span class="pill-hint"><span class="kbd"><span>Ctrl</span><span>Alt</span><span>1–9</span></span> on selected text</span></div></div> ${banner('b-trans', `<div class="app-dots">${dots.map(([t, x, y]) => `<span style="left:${x}px;top:${y}%">${t}</span>`).join('')}</div> <h2>Transform works anywhere you write</h2><p style="max-width:330px">Apply a Transform to rewrite, clean up, or restructure text after you dictate.</p> <button class="btn dark" data-act="tf-try">Try it out</button> <button class="btn" style="background:none;color:#fff" data-act="tf-how">How it works</button>`)} <div class="auto-row"><div class="grow"><h4>Auto Apply After Dictation</h4><p>Run a transform on everything you dictate, automatically.</p></div> <span class="muted" title="Runs after Auto cleanup and Style">${ic('info')}</span> <select class="select" data-set="autoTransform.id">${tf.map(t => `<option value="${t.id}" ${st.autoTransform?.id === t.id ? 'selected' : ''}>${esc(t.name)}</option>`).join('')}</select> <button class="toggle ${st.autoTransform?.enabled ? 'on' : ''}" data-toggle="autoTransform.enabled"></button></div> <div class="sec-head"><h2>My Transforms</h2><button data-act="tf-reset" style="display:flex;gap:8px;align-items:center">${ic('undo')}Reset to defaults</button><button class="btn" data-act="tf-edit">Create New</button></div> <div class="cards3">${tf.map(t => `<button class="tcard" data-act="tf-edit" data-id="${t.id}">${keyBadge(t.key || '–')}<h4>${esc(t.name)}</h4><p>${esc(t.desc || '')}</p></button>`).join('')} <button class="tcard" data-act="tf-edit"><span class="plus-dot">${ic('plus')}</span><h4>Create your own</h4><p>Write your own prompt</p></button></div></div>`; } // ---------------------------------------------------------------- assistant const modelName = id => (S.runtime.assistantModels || []).find(m => m.id === id)?.name || id || ''; const secs = ms => ms >= 60000 ? fmtDur(ms) : `${(ms / 1000).toFixed(1)} s`; function assistantPage() { const a = S.settings.assistant || {}, rt = S.runtime, running = rt.assistant === 'running'; const all = [...(S.commands || [])].sort((x, y) => y.ts - x.ts); const live = running && all[0] && !all[0].reply ? all[0] : null; const items = all.filter(c => matches('asst', c.text, c.reply)); const groups = []; items.forEach(c => { const d = fmtDay(c.ts); if (!groups.length || groups.at(-1).d !== d) groups.push({ d, xs: [] }); groups.at(-1).xs.push(c); }); const log = groups.map(g => `<div class="day"><span>${g.d}</span></div><div class="list asst-log">${g.xs.map(c => cmdRow(c, live?.id)).join('')}</div>`).join('');Lines 1–500 of 1108. Load more
// lilt hub UI. Renders every page from the state main.js pushes over window.lilt. (() => { 'use strict'; const $ = (s, r = document) => r.querySelector(s); const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); const get = (o, p) => p.split('.').reduce((a, k) => a == null ? a : a[k], o); const wc = t => (String(t || '').match(/\S+/g) || []).length; // Icons (24px stroke paths, drawn in the lucide style). const I = { mic: '<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><path d="M12 19v3"/>', rec: '<circle cx="12" cy="12" r="9.5"/><circle cx="12" cy="12" r="4" fill="currentColor"/>', chart: '<path d="M3 3v16a2 2 0 0 0 2 2h16"/><path d="M8 17v-5M13 17V8M18 17v-3"/>', book: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9h1M10 13h4M10 17h4"/>', scissors: '<circle cx="6" cy="6" r="3"/><path d="M8.12 8.12 12 12M20 4 8.12 15.88"/><circle cx="6" cy="18" r="3"/><path d="M14.8 14.8 20 20"/>', type: '<path d="M3 6V4h11v2M8.5 4v15M6 19h5"/><path d="M14 11h7M17.5 11v8M16 19h3"/>', wand: '<path d="m21.64 3.64-1.28-1.28a1.21 1.21 0 0 0-1.72 0L2.36 18.64a1.21 1.21 0 0 0 0 1.72l1.28 1.28a1.2 1.2 0 0 0 1.72 0L21.64 5.36a1.2 1.2 0 0 0 0-1.72"/><path d="m14 7 3 3M5 6v4M19 14v4M10 2v2M7 8H3M21 16h-4M11 3H9"/>', pad: '<path d="M13.4 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7.4"/><path d="M2 6h4M2 10h4M2 14h4M2 18h4"/><path d="M21.38 5.63a1 1 0 1 0-3-3l-5 5a2 2 0 0 0-.5.85l-.84 2.87a.5.5 0 0 0 .62.62l2.87-.84a2 2 0 0 0 .85-.5z"/>', users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>', gift: '<rect x="3" y="8" width="18" height="4" rx="1"/><path d="M12 8v13M19 12v7a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-7"/><path d="M7.5 8a2.5 2.5 0 0 1 0-5C10 3 12 8 12 8s2-5 4.5-5a2.5 2.5 0 0 1 0 5"/>', gear: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>', help: '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3M12 17h.01"/>', sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>', moon: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>', monitor: '<rect width="20" height="14" x="2" y="3" rx="2"/><path d="M8 21h8M12 17v4"/>', bell: '<path d="M10.27 21a2 2 0 0 0 3.46 0"/><path d="M3.26 15.33A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.67C19.41 13.96 18 12.5 18 8A6 6 0 0 0 6 8c0 4.5-1.41 5.96-2.74 7.33"/>', user: '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="10" r="3"/><path d="M7 20.66V19a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1.66"/>', sidebar: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/>', search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>', x: '<path d="M18 6 6 18M6 6l12 12"/>', play: '<path d="M7 4.5v15l12-7.5z"/>', copy: '<rect width="13" height="13" x="8" y="8" rx="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>', flag: '<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1zM4 22v-7"/>', more: '<circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/>', sort: '<path d="m21 16-4 4-4-4M17 20V4M3 8l4-4 4 4M7 4v16"/>', refresh: '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>', plus: '<path d="M5 12h14M12 5v14"/>', check: '<path d="M20 6 9 17l-5-5"/>', info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/>', cal: '<rect width="18" height="18" x="3" y="4" rx="2"/><path d="M16 2v4M8 2v4M3 10h18M8 14h.01M12 14h.01M16 14h.01M8 18h.01M12 18h.01M16 18h.01"/>', sliders: '<path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4"/>', laptop: '<path d="M20 16V7a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v9m16 0H4m16 0 1.28 2.55a1 1 0 0 1-.9 1.45H3.62a1 1 0 0 1-.9-1.45L4 16"/>', hash: '<path d="M4 9h16M4 15h16M10 3 8 21M16 3l-2 18"/>', plug: '<path d="M12 22v-5M9 8V2M15 8V2M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"/>', mcp: '<path d="m15 12-8.5 8.5a2.12 2.12 0 0 1-3-3L12 9"/><path d="M17.64 15 22 10.64M20.91 11.7l-1.25-1.25a2.4 2.4 0 0 1 0-3.4l.86-.86a2.4 2.4 0 0 0 0-3.4L17.3 0"/>', card: '<rect width="20" height="14" x="2" y="5" rx="2"/><path d="M2 10h20"/>', shield: '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/>', trash: '<path d="M3 6h18M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>', pencil: '<path d="M21.17 6.81a1 1 0 0 0-3.99-3.99L3.84 16.17a2 2 0 0 0-.5.83l-1.32 4.35a.5.5 0 0 0 .62.62l4.35-1.32a2 2 0 0 0 .83-.5z"/>', back: '<path d="m12 19-7-7 7-7M19 12H5"/>', undo: '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/>', inf: '<path d="M12 12c-2-2.67-4-4-6-4a4 4 0 1 0 0 8c2 0 4-1.33 6-4Zm0 0c2 2.67 4 4 6 4a4 4 0 0 0 0-8c-2 0-4 1.33-6 4Z"/>', bot: '<path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2M20 14h2M15 13v2M9 13v2"/>', bubble: '<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/>', mail: '<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>', msg: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><path d="M13 8H7M17 12H7"/>', share: '<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8M16 6l-4-4-4 4M12 2v13"/>', trend: '<path d="M22 7 13.5 15.5 8.5 10.5 2 17M16 7h6v6"/>', download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/>', stop: '<rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor"/>', folder: '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>', file: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4M10 9H8M16 13H8M16 17H8"/>', cloud: '<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"/>', }; const ic = (n, cls = '') => `<svg class="i ${cls}" viewBox="0 0 24 24">${I[n] || ''}</svg>`; // lilt mark: a cursive l that trails off into a lilting tail and the orange accent dot. const logo = '<svg viewBox="40 34 180 186"><path d="M58 196c18-6 24-40 30-92 4-36 12-56 26-56 12 0 14 18 8 46-8 40-30 76-30 98 0 10 6 14 14 14 20 0 34-34 50-62 8-14 18-22 30-22" fill="none" stroke="currentColor" stroke-width="20" stroke-linecap="round" stroke-linejoin="round"/><circle cx="198" cy="122" r="15" fill="#fc9e40"/></svg>'; // ---------------------------------------------------------------- state let S = null; const ui = { page: 'home', tab: {}, search: {}, searchOpen: {}, sort: {}, settings: null, note: null, meeting: null, dirty: false, side: true, cmdOpen: {}, }; const toast = (msg, ms = 2600) => { const t = $('#toast'); t.textContent = msg; t.classList.remove('hidden'); clearTimeout(toast.t); toast.t = setTimeout(() => t.classList.add('hidden'), ms); }; function applyTheme() { const t = S.settings.theme; const dark = t === 'dark' || (t === 'system' && matchMedia('(prefers-color-scheme: dark)').matches); document.documentElement.dataset.theme = dark ? 'dark' : 'light'; $('#theme-btn').innerHTML = ic(dark ? 'sun' : 'moon'); } matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => S && applyTheme()); // Re-rendering while the user types would eat keystrokes; wait for blur. function editing() { const a = document.activeElement; return a && /^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName) && a.type !== 'checkbox'; } function onState(st) { S = st; if (editing()) { ui.dirty = true; return; } render(); } document.addEventListener('focusout', () => setTimeout(() => { if (ui.dirty && !editing()) { ui.dirty = false; render(); } }, 0)); async function setSetting(path, value) { // optimistic local update so toggles feel instant const keys = path.split('.'); let o = S.settings; keys.slice(0, -1).forEach(k => { o[k] = o[k] && typeof o[k] === 'object' ? o[k] : {}; o = o[k]; }); o[keys.at(-1)] = value; render(); await lilt.set(path, value); } function go(route) { const [page, sub] = route.split('/'); if (page === 'settings') { ui.settings = sub || 'general'; renderSettings(); return; } ui.page = page; ui.note = null; ui.meeting = null; if (page === 'insights' && sub) ui.tab.insights = sub; if (page === 'scratchpad' && sub === 'new') return newNote(); closeMenus(); render(); $('#main').scrollTop = 0; } // ---------------------------------------------------------------- stats const dayKey = ts => { const d = new Date(ts); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; }; const fmtK = n => n >= 1000 ? (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K' : String(n); const fmtTime = ts => new Date(ts).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }).replace(' ', '').toLowerCase(); const fmtDay = ts => { const k = dayKey(ts), now = Date.now(); if (k === dayKey(now)) return 'TODAY'; if (k === dayKey(now - 864e5)) return 'YESTERDAY'; return new Date(ts).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }).toUpperCase(); }; const fmtDur = ms => { const s = Math.round(ms / 1000); return s >= 3600 ? `${Math.floor(s / 3600)}h ${Math.floor(s % 3600 / 60)}m` : `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; }; const TERMS = /^(st|kitty|alacritty|foot|xterm|urxvt|wezterm|ghostty|konsole|gnome-terminal|terminator|code|cursor|zed|windsurf)/i; function category(app = '') { const a = app.toLowerCase(); if (/signal|telegram|discord|whatsapp|element|messages/.test(a)) return 'personal'; if (/slack|teams|zulip|mattermost/.test(a)) return 'work'; if (/thunderbird|geary|evolution|mutt|mail/.test(a)) return 'email'; if (/libreoffice|soffice|obsidian|notion|typora|onlyoffice|abiword|writer/.test(a)) return 'docs'; if (TERMS.test(a)) return 'ai'; return 'other'; } function stats() { const h = S.history.filter(x => !x.dismissed && x.text); const words = h.reduce((a, x) => a + (x.words || wc(x.text)), 0); const timed = h.filter(x => x.ms > 1500); const mins = timed.reduce((a, x) => a + x.ms, 0) / 60000; const wpm = mins ? Math.round(timed.reduce((a, x) => a + (x.words || wc(x.text)), 0) / mins) : 0; const perDay = {}; h.forEach(x => { const k = dayKey(x.ts); perDay[k] = (perDay[k] || 0) + (x.words || wc(x.text)); }); let streak = 0, t = Date.now(); if (!perDay[dayKey(t)]) t -= 864e5; while (perDay[dayKey(t)]) { streak++; t -= 864e5; } let longest = 0, run = 0, prev = null; Object.keys(perDay).sort().forEach(k => { const d = new Date(k + 'T12:00').getTime(); run = prev && Math.round((d - prev) / 864e5) === 1 ? run + 1 : 1; longest = Math.max(longest, run); prev = d; }); const now = new Date(), m0 = new Date(now.getFullYear(), now.getMonth(), 1).getTime(), m1 = new Date(now.getFullYear(), now.getMonth() - 1, 1).getTime(); const thisMonth = h.filter(x => x.ts >= m0).reduce((a, x) => a + (x.words || wc(x.text)), 0); const before = words - thisMonth; const growth = before ? Math.round(thisMonth / before * 100) : (thisMonth ? 100 : 0); let corrected = 0; h.forEach(x => { if (!x.raw || x.raw === x.text) return; const bag = {}; (x.raw.toLowerCase().match(/[\w']+/g) || []).forEach(w => bag[w] = (bag[w] || 0) + 1); (x.text.toLowerCase().match(/[\w']+/g) || []).forEach(w => { if (bag[w]) bag[w]--; else corrected++; }); }); const dictWords = S.dictionary.map(d => (d.to || d.word).toLowerCase()); const dictFixes = h.reduce((a, x) => a + dictWords.filter(w => w && x.text.toLowerCase().includes(w)).length, 0); const cats = { other: 0, ai: 0, docs: 0, personal: 0, email: 0, work: 0 }; h.forEach(x => cats[category(x.app)]++); const apps = new Set(h.map(x => (x.app || '').toLowerCase()).filter(Boolean)).size; return { h, words, wpm, streak, longest, perDay, growth, corrected, dictFixes, cats, apps, m1 }; } // ---------------------------------------------------------------- shell function render() { if (!S) return; applyTheme(); $('#app').classList.toggle('side-off', !ui.side); renderSide(); const pages = { home, notetaker, assistant: assistantPage, insights, dictionary, snippets, style, transforms, scratchpad }; $('#main').innerHTML = (pages[ui.page] || home)(); if (ui.settings) renderSettings(); } const NAV = [ ['home', 'mic', 'Dictation'], ['notetaker', 'rec', 'Notetaker'], ['assistant', 'bot', 'Assistant'], ['insights', 'chart', 'Insights'], ['dictionary', 'book', 'Dictionary'], ['snippets', 'scissors', 'Snippets'], ['style', 'type', 'Style'], ['transforms', 'wand', 'Transforms'], ['scratchpad', 'pad', 'Scratchpad'], ]; function renderSide() { $('#side').innerHTML = ` <div class="logo">${logo}<span>lilt</span></div> <nav class="nav">${NAV.map(([id, icon, label]) => `<button class="${ui.page === id ? 'on' : ''}" data-go="${id}">${ic(icon)}${label}</button>`).join('')}</nav> <div class="side-fill"></div> <div class="side-links"> <button data-go="settings/general">${ic('gear')}Settings</button> <button data-act="help">${ic('help')}Help</button> </div>`; const tb = document.querySelectorAll('.topbar .icon-btn'); tb[0].innerHTML = ic('sidebar'); tb[2].innerHTML = ic('bell'); tb[3].innerHTML = ic('user'); } const banner = (cls, html, closeKey) => `<div class="banner ${cls}">${closeKey ? `<button class="close" data-act="dismiss" data-key="${closeKey}">${ic('x')}</button>` : ''}${html}</div>`; const tabs = (key, list, tools = '') => { const cur = ui.tab[key] || list[0][0]; return `<div class="tabs">${list.map(([id, label, extra]) => `<button class="${cur === id ? 'on' : ''}" data-tab="${key}:${id}">${label}${extra || ''}</button>`).join('')}<span class="spacer"></span>${tools}</div>`; }; const listTools = key => `<span class="tools"> <button data-act="search" data-key="${key}" title="Search">${ic('search')}</button> <button data-act="sort" data-key="${key}" title="Sort">${ic('sort')}</button> <button data-act="refresh" title="Refresh">${ic('refresh')}</button></span>`; const searchBox = key => ui.searchOpen[key] ? `<input class="search-in" data-search="${key}" placeholder="Search" value="${esc(ui.search[key] || '')}" autofocus>` : ''; const matches = (key, ...fields) => { const q = (ui.search[key] || '').toLowerCase(); return !q || fields.some(f => String(f || '').toLowerCase().includes(q)); }; // ---------------------------------------------------------------- home function home() { const st = stats(), set = S.settings, rt = S.runtime; const items = S.history.filter(x => matches('home', x.text, x.raw)).sort((a, b) => b.ts - a.ts); const groups = []; items.forEach(x => { const d = fmtDay(x.ts); if (!groups.length || groups.at(-1).d !== d) groups.push({ d, xs: [] }); groups.at(-1).xs.push(x); }); const hist = groups.map((g, i) => ` <div class="day"><span>${g.d}</span>${i === 0 ? `<button data-act="search" data-key="home">${ic('search')}</button>` : ''}</div> ${i === 0 ? searchBox('home') : ''} <div class="list hist">${g.xs.map(histRow).join('')}</div>`).join(''); const hint = `Hold <b>${esc(rt.hotkeyLabel || set.hotkey?.label || 'Right Alt')}</b> and speak into any text field. Double-tap it for hands-free.`; const empty = `<div class="day"><span>TODAY</span></div><div class="list"><div class="row"><span class="grow muted">${hint}</span></div></div>`; return `<div class="home"> <div class="home-head"><h1>Welcome back, ${esc(set.firstName || 'there')}</h1></div> <div> ${rt.engine !== 'ready' ? `<div class="list" style="margin-bottom:20px"><div class="row"><span class="status-dot ${rt.engine}"></span><span class="grow">${engineText()}</span><button class="btn sm ghost" data-go="settings/general">Models</button></div></div>` : ''} ${groups.length ? hist : (ui.search.home ? searchBox('home') + '<div class="empty">No matches</div>' : empty)} </div> <div class="home-side"> <div class="stats-card"> <div class="nums"> <div class="num"><b>${fmtK(st.words)}</b>total words</div> <div class="num"><b>${st.wpm}</b>wpm</div> <div class="num" style="margin:0"><b>${st.streak}</b>day streak</div> </div> </div> </div></div>`; } function engineText() { const rt = S.runtime; if (rt.engine === 'loading') return 'Loading the speech model onto the GPU…'; if (rt.engine === 'error') return `Speech engine error: ${esc(rt.engineError || 'unknown')}`; return 'Speech engine is off. Pick a downloaded model in Settings.'; } function histRow(x) { if (x.dismissed) return `<div class="row"><span class="time">${fmtTime(x.ts)}</span><span class="grow text dim">This transcription was dismissed. <button data-hist="recover" data-id="${x.id}">Recover</button></span></div>`; const text = x.text ? esc(x.text) : '<span class="muted">No speech detected</span>'; return `<div class="row" data-row="${x.id}"><span class="time">${fmtTime(x.ts)}</span> <span class="grow text">${text}${x.flagged ? `<span class="tag">${ic('flag')}</span>` : ''}</span> <span class="acts"> ${x.audio ? `<button data-act="play" data-id="${x.id}" title="Play">${ic('play')}</button>` : ''} <button data-act="copy" data-id="${x.id}" title="Copy">${ic('copy')}</button> <button data-hist="flag" data-id="${x.id}" title="${x.flagged ? 'Unflag' : 'Send feedback'}">${ic('flag')}</button> <button data-act="hist-more" data-id="${x.id}" title="More">${ic('more')}</button> </span></div>`; } // ---------------------------------------------------------------- notetaker function notetaker() { const rt = S.runtime; if (ui.meeting) return meetingView(); const on = rt.notetaker; const today = S.meetings.filter(m => dayKey(m.ts) === dayKey(Date.now())); const list = S.meetings.filter(m => matches('notetaker', m.title, m.summary, m.transcript)).sort((a, b) => b.ts - a.ts); let body; body = list.length ? `<div class="list">${list.map(m => `<div class="row" data-meeting="${m.id}" style="cursor:pointer"> <span class="doc-dot">${ic('file')}</span><span class="grow"><b style="font-weight:500">${esc(m.title || 'Untitled meeting')}</b> <div class="muted" style="font-size:13px">${new Date(m.ts).toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' })} · ${fmtDur(m.ms || 0)}</div></span></div>`).join('')}</div>` : `<div class="list"><div class="row"><span class="doc-dot">${ic('file')}</span><span class="grow" style="font-weight:500">Your meetings will appear here</span></div></div>`; return `<div class="page narrow"> <div class="page-head"><h1>Notetaker</h1><div class="head-actions"> <button class="icon-btn" data-go="settings/notetaker" title="Notetaker settings">${ic('gear')}</button> <button class="btn ghost" data-act="notetaker-${on ? 'stop' : 'start'}">${on ? ic('stop') + 'Stop Notetaker' : '<span class="green-dot"></span>Start Notetaker'}</button> </div></div> <div class="nt-card"><div class="label"><span>TODAY</span><button data-act="refresh" class="muted">${ic('refresh')}</button></div> ${on ? `<div class="live"><span class="rec"></span><span class="grow"><b style="font-weight:500">Recording</b> <span class="muted">microphone and system audio</span></span> <span id="nt-timer" data-start="${on.startedAt}">${fmtDur(Date.now() - on.startedAt)}</span><button class="btn sm" data-act="notetaker-stop">Stop</button></div>` : ''} ${today.length ? today.map(m => `<div class="live" data-meeting="${m.id}" style="cursor:pointer"><span class="grow">${esc(m.title || 'Untitled meeting')}</span><span class="muted">${fmtTime(m.ts)}</span></div>`).join('') : on ? '' : `<div class="nt-empty">${ic('cal')}<h4>No meetings found</h4><p>Start Notetaker during a call for a transcript, summary and action items. Audio stays on this machine.</p> <button class="btn line" data-act="notetaker-start">Start Notetaker</button></div>`} </div> ${tabs('notetaker', [['past', 'Past notes']], `<span class="tools"><button data-act="search" data-key="notetaker">${ic('search')}</button></span>`)} ${searchBox('notetaker')}${body}</div>`; } function meetingView() { const m = S.meetings.find(x => x.id === ui.meeting); if (!m) { ui.meeting = null; return notetaker(); } return `<div class="page narrow"> <div class="page-head"><button class="icon-btn" data-act="meeting-back">${ic('back')}</button><h1>${esc(m.title || 'Untitled meeting')}</h1> <div class="head-actions"><button class="btn ghost sm" data-act="copy-text" data-text="${esc(m.summary || '')}">${ic('copy')}Copy summary</button> <button class="icon-btn" data-act="meeting-delete" data-id="${m.id}" title="Delete">${ic('trash')}</button></div></div> <p class="muted" style="margin-top:-14px">${new Date(m.ts).toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'short' })} · ${fmtDur(m.ms || 0)}</p> ${m.audio ? `<audio controls style="width:100%;margin:6px 0 20px" data-audio="${m.id}"></audio>` : ''} <div class="nt-card md">${m.summary ? md(m.summary) : '<p class="muted">Summary is being written…</p>'}</div> <h3 style="font-size:16px;font-weight:600">Transcript</h3> <div class="muted" style="white-space:pre-wrap;line-height:1.7">${esc(m.transcript || 'Transcribing…')}</div></div>`; } function md(s) { return esc(s).split('\n').map(l => { l = l.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>'); if (/^#{1,3} /.test(l)) return `<h3>${l.replace(/^#+ /, '')}</h3>`; if (/^\s*[-*] /.test(l)) return `<li>${l.replace(/^\s*[-*] (\[[ x]\] )?/, '')}</li>`; return l.trim() ? `<p style="margin:4px 0">${l}</p>` : ''; }).join('').replace(/(<li>.*?<\/li>)+/g, m => `<ul>${m}</ul>`); } // ---------------------------------------------------------------- insights function insights() { const st = stats(), sub = ui.tab.insights || 'usage'; const head = `<div class="page-head"><h1>Insights</h1><button class="icon-btn" data-act="share-insights" title="Copy summary">${ic('share')}</button></div> ${tabs('insights', [['usage', 'Your usage'], ['voice', 'Your voice']])}`; if (sub === 'voice') return `<div class="page">${head}${voiceReport(st)}</div>`; const f = Math.min(st.wpm / 150, 1); const top = st.wpm >= 110 ? '1%' : st.wpm >= 90 ? '5%' : st.wpm >= 75 ? '10%' : st.wpm >= 60 ? '25%' : st.wpm >= 45 ? '50%' : null; const books = [['Dr. Seuss book', 1600], ['short story', 7500], ['novella', 30000], ['The Great Gatsby', 47094], ['Harry Potter book', 76944], ['Lord of the Rings', 481103]]; const [bName, bLen] = books.filter(b => st.words >= b[1]).at(-1) || books[0]; const nBooks = Math.max(1, Math.floor(st.words / bLen)); const total = Object.values(st.cats).reduce((a, b) => a + b, 0) || 1; const rows = [['inf', 'other', 'other tasks'], ['bot', 'ai', 'AI prompts'], ['file', 'docs', 'documents'], ['bubble', 'personal', 'personal messages'], ['mail', 'email', 'emails'], ['msg', 'work', 'work messages']]; return `<div class="page">${head} <div class="ins-grid"> <div class="ins-card"><div class="big">${st.wpm}</div><div class="cap">Words per minute <span title="Words dictated divided by recording time">${ic('info')}</span></div> <div class="gauge"><svg viewBox="0 0 160 92"><path d="M12 84a68 68 0 0 1 136 0" stroke="var(--cell)" stroke-width="14" fill="none" stroke-linecap="round" pathLength="100"/> <path d="M12 84a68 68 0 0 1 136 0" stroke="var(--teal-1)" stroke-width="14" fill="none" stroke-linecap="round" pathLength="100" stroke-dasharray="${f * 100} 100"/></svg> <div>${top ? `Top<b>${top}</b>` : '<b>—</b>'}</div></div></div> <div class="ins-card"><div class="big">${st.corrected + st.dictFixes}</div><div class="cap">Fixes made by lilt</div><hr> <div class="kv"><span>${st.corrected} words corrected</span><span title="Words changed by cleanup and Style">${ic('info')}</span></div> <div class="kv"><span>${st.dictFixes} dictionary fixes</span><span title="Dictations that used your Dictionary">${ic('info')}</span></div></div> <div class="ins-card w2"><div class="ins-headrow"><div class="big">${st.words.toLocaleString()}</div>${st.growth ? `<span class="up">${ic('trend')}${st.growth}% this month</span>` : ''}</div> <div class="cap">Total words dictated</div><hr><div>You've written ${nBooks} ${bName}${nBooks > 1 && !bName.startsWith('The') && !bName.startsWith('Lord') ? 's' : ''}!</div> <div class="split"><span style="flex:1">${ic('monitor')}Desktop</span></div></div> <div class="ins-card w2"><div class="ins-headrow"><span class="ins-title">Desktop usage</span><span class="cap">Total apps used | ${st.apps}</span></div> ${rows.map(([icon, k, label]) => { const p = Math.round(st.cats[k] / total * 100); return `<div class="usage-row">${ic(icon)}<div class="track"><span class="bar" style="width:${Math.max(p * 0.55, 6)}%">${p}%</span>${st.cats[k]} ${label}</div></div>`; }).join('')}</div> <div class="ins-card w2"><div class="ins-headrow"><span class="ins-title">${st.streak} day streak</span><span class="cap">Longest streak | ${st.longest} days</span></div> ${heatmap(st)}</div> </div></div>`; } function heatmap(st) { const weeks = 18, end = new Date(); end.setHours(12, 0, 0, 0); const start = new Date(end); start.setDate(end.getDate() - end.getDay() - (weeks - 1) * 7); const vals = Object.values(st.perDay).sort((a, b) => a - b); const q = p => vals[Math.floor(p * (vals.length - 1))] || 0; const level = v => !v ? '' : v >= q(.75) ? 'l4' : v >= q(.5) ? 'l3' : v >= q(.25) ? 'l2' : 'l1'; let months = '<span></span>', lastM = -1; for (let w = 0; w < weeks; w++) { const d = new Date(start); d.setDate(start.getDate() + w * 7); months += `<span>${d.getMonth() !== lastM ? d.toLocaleDateString('en-US', { month: 'short' }) : ''}</span>`; lastM = d.getMonth(); } const today = dayKey(Date.now()); let cells = ''; ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].forEach((name, r) => { cells += `<span>${name}</span>`; for (let w = 0; w < weeks; w++) { const d = new Date(start); d.setDate(start.getDate() + w * 7 + r); const k = dayKey(d.getTime()); cells += d > end ? '<span></span>' : `<i class="${level(st.perDay[k])}${k === today && st.streak ? ' cur' : ''}" title="${k}: ${st.perDay[k] || 0} words"></i>`; } }); return `<div class="heat">${months}${cells}</div> <div class="legend">More <i style="background:var(--teal-1)"></i><i style="background:var(--teal-2)"></i><i style="background:var(--teal-3)"></i><i style="background:var(--teal-4)"></i> Less <span style="flex:1"></span><i style="border:1.5px solid var(--teal-4)"></i> Current streak</div>`; } function voiceReport(st) { if (!st.h.length) return '<div class="empty">Dictate a few times and your voice profile will show up here.</div>'; const stop = new Set('the a an and or but to of in on at for is it i you we that this be are was with as so my me just like um uh do not have can will your our if what about from they them he she its im dont'.split(' ')); const freq = {}; st.h.forEach(x => (x.text.toLowerCase().match(/[a-z][a-z']+/g) || []).forEach(w => { if (!stop.has(w) && w.length > 2 && !w.includes("'")) freq[w] = (freq[w] || 0) + 1; })); const top = Object.entries(freq).sort((a, b) => b[1] - a[1]).slice(0, 24); const hours = Array(24).fill(0); st.h.forEach(x => hours[new Date(x.ts).getHours()]++); const peak = hours.indexOf(Math.max(...hours)); const apps = {}; st.h.forEach(x => { if (x.app) apps[x.app] = (apps[x.app] || 0) + 1; }); const fav = Object.entries(apps).sort((a, b) => b[1] - a[1])[0]; const longest = st.h.reduce((a, x) => (x.words || 0) > (a.words || 0) ? x : a, st.h[0]); const avg = Math.round(st.words / st.h.length); const hourName = h => new Date(2000, 0, 1, h).toLocaleTimeString('en-US', { hour: 'numeric' }).toLowerCase().replace(' ', ''); return `<div class="ins-grid"> <div class="ins-card"><div class="big">${avg}</div><div class="cap">Words per dictation</div></div> <div class="ins-card"><div class="big">${hourName(peak)}</div><div class="cap">Most active hour</div></div> <div class="ins-card w2"><div class="big">${esc(fav ? fav[0] : '—')}</div><div class="cap">Where you dictate most</div></div> <div class="ins-card w2"><span class="ins-title">Your words</span><div class="words">${top.map(([w, n]) => `<span title="${n} times">${esc(w)}</span>`).join('')}</div></div> <div class="ins-card w2"><span class="ins-title">Longest dictation</span><p class="muted" style="margin:10px 0 0">${longest.words || wc(longest.text)} words · ${new Date(longest.ts).toLocaleDateString()}</p> <p style="margin:8px 0 0;max-height:120px;overflow:hidden">${esc(longest.text)}</p></div></div>`; } // ---------------------------------------------------------------- dictionary + snippets function dictionary() { const sorted = [...S.dictionary].filter(d => matches('dict', d.word, d.to)); if (ui.sort.dict) sorted.sort((a, b) => a.word.localeCompare(b.word)); const rows = sorted.map(d => `<div class="row"><span class="grow entry">${esc(d.word)}${d.to ? `<span class="arrow">→</span>${esc(d.to)}` : ''}${d.auto ? ' ✨' : ''}</span> <span class="acts"><button data-act="dict-edit" data-id="${d.id}" title="Edit">${ic('pencil')}</button><button data-act="remove" data-coll="dictionary" data-id="${d.id}" title="Delete">${ic('trash')}</button></span></div>`).join(''); const examples = ['Samir', 'Sara', 'Karol', 'Spyder'].filter(w => !S.dictionary.some(d => d.word === w)); return `<div class="page narrow"> <div class="page-head"><h1>Dictionary</h1><div class="head-actions"> <button class="btn" data-act="import" data-coll="dictionary">Import</button> <button class="btn" data-act="dict-edit">Add new</button></div></div> ${tabs('dict', [['all', 'All']], listTools('dict'))} ${searchBox('dict')} ${S.settings.dismissed?.dictBanner ? '' : banner('b-dict', `<h2>lilt spells the way <em>you</em> do.</h2><p>Correct a spelling once or add it here manually, so your <b>personal terms, jargon, or uncommon names</b> are spelled right in both dictations and meeting notes.</p> <div class="chips"><button class="chip solid" data-act="dict-edit">Add new word</button>${examples.map(w => `<button class="chip" data-act="dict-quick" data-word="${esc(w)}">${esc(w)}</button>`).join('')}</div>`, 'dictBanner')} ${rows ? `<div class="list">${rows}</div>` : '<div class="empty">No words yet</div>'}</div>`; } function snippets() { const sorted = [...S.snippets].filter(x => matches('snip', x.trigger, x.text)); if (ui.sort.snip) sorted.sort((a, b) => a.trigger.localeCompare(b.trigger)); const rows = sorted.map(x => `<div class="row"><span class="grow entry" style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(x.trigger)}<span class="arrow">→</span>${esc(x.text)}</span> <span class="acts"><button data-act="snip-edit" data-id="${x.id}" title="Edit">${ic('pencil')}</button><button data-act="remove" data-coll="snippets" data-id="${x.id}" title="Delete">${ic('trash')}</button></span></div>`).join(''); const ex = [['“my LinkedIn”', 'https://www.linkedin.com/in/john-doe/'], ['“rewrite prompt”', 'Rewrite this to be more concise...'], ['“intro email”', 'Hey, would love to find some time to chat later...']]; return `<div class="page narrow"> <div class="page-head"><h1>Snippets</h1><div class="head-actions"> <button class="btn" data-act="import" data-coll="snippets">Import</button> <button class="btn" data-act="snip-edit">Add new</button></div></div> ${tabs('snip', [['all', 'All']], listTools('snip'))} ${searchBox('snip')} ${S.settings.dismissed?.snipBanner ? '' : banner('b-snip', `<h2>The stuff <em>you</em> shouldn't have to re-type.</h2><p>Save text you type often — an email, intro, or prompt — then say a word to drop it in instantly.</p> <div class="chip-rows">${ex.map(([a, b]) => `<span class="chip">${a}</span><span class="chip">${b}</span>`).join('')}</div> <button class="btn dark" data-act="snip-edit">Add new snippet</button>`, 'snipBanner')} ${rows ? `<div class="list">${rows}</div>` : '<div class="empty">No snippets yet</div>'}</div>`; } // ---------------------------------------------------------------- style const STYLE_APPS = { personal: 'Signal, Telegram, Discord, WhatsApp, Element', work: 'Slack, Teams, Zulip, Mattermost', email: 'Thunderbird, Geary, Gmail, Proton Mail, Outlook', other: 'everything else: terminals, editors, browsers, notes' }; const STYLE_SAMPLES = { personal: ['Hey, are you free for lunch tomorrow? Let’s do 12 if that works for you.', 'Hey are you free for lunch tomorrow? Let’s do 12 if that works for you', 'hey are you free for lunch tomorrow? let’s do 12 if that works for you'], work: ['Hi team, the release is ready for review. Please take a look before Friday.', 'Hi team, the release is ready for review. Take a look before Friday', 'hi team, release is ready for review. take a look before friday'], email: ['Hi Sarah,\n\nThanks for the update. I’ll review the draft and send notes by Thursday.\n\nBest,\nAda', 'Hi Sarah,\n\nThanks for the update. I’ll look over the draft and send notes by Thursday\n\nAda', 'hi sarah\n\nthanks for the update, i’ll look over the draft and send notes by thursday\n\nada'], other: ['Update the config so the server restarts cleanly after a deploy.', 'Update the config so the server restarts cleanly after a deploy', 'update the config so the server restarts cleanly after a deploy'], }; function style() { const sub = ui.tab.style || 'personal'; const head = `<div class="page narrow"><div class="page-head"><h1>Style</h1></div> ${tabs('style', [['personal', 'Personal messages'], ['work', 'Work messages'], ['email', 'Email'], ['other', 'Other'], ['cleanup', 'Auto cleanup', ' <span class="beta purple">Beta</span>']])}`; if (sub === 'cleanup') { const lv = S.settings.cleanup || 'light'; const cards = [['none', 'None', 'Transcribes exactly what you said, including mistakes', 'hey joey, we still on for coffee or? I think we maybe should leave earlier to make it there in time there might um be traffic. What are you thinking?'], ['light', 'Light', 'Cleans up filler words and grammar', 'Hey Joey, are we still on for coffee? I think we should leave earlier to make it there in time. There might be traffic. What are you thinking?'], ['medium', 'Medium', 'Edits for clarity and conciseness', 'Hey Joey, are we still on for coffee? We should leave earlier; there might be traffic. What do you think?']]; return head + banner('b-clean', `<h2>Auto Cleanup applies to all your dictations</h2><p>Choose the level of cleanup that’s automatically applied every time, across all apps.</p><p>Note your original dictation is never lost. Just go to the three dots next to a recent dictation in the Home tab, then click “Undo AI edit.”</p>`) + `<div class="cards3">${cards.map(([id, t, d, s]) => `<button class="scard ${lv === id ? 'on' : ''}" data-act="set" data-path="cleanup" data-val="${id}"><h3>${t}</h3><p>${d}</p><div class="sample">${s}</div></button>`).join('')}</div></div>`; } const cur = S.settings.styles?.[sub] || 'formal'; const sm = STYLE_SAMPLES[sub]; const cards = [['formal', 'Formal.', 'Caps + Punctuation', sm[0]], ['casual', 'Casual', 'Caps + Less punctuation', sm[1]], ['veryCasual', 'very casual', 'No caps + Less punctuation', sm[2]]]; return head + banner('b-voice', `<h2>Make lilt sound like <em>you</em></h2><p>Set up different writing styles for different apps.</p>`) + `<p class="apps-line">Applies in ${STYLE_APPS[sub]}.</p> <div class="cards3">${cards.map(([id, t, d, s]) => `<button class="scard ${cur === id ? 'on' : ''}" data-act="set" data-path="styles.${sub}" data-val="${id}"><h3>${t}</h3><p>${d}</p><div class="sample" style="white-space:pre-wrap">${s}</div></button>`).join('')}</div></div>`; } // ---------------------------------------------------------------- transforms const DEFAULT_TRANSFORMS = [ { id: 'polish', name: 'Polish', desc: 'Improve clarity and conciseness', key: '1', prompt: 'Rewrite the text to be clearer and more concise. Fix grammar and lilt. Keep the meaning, voice and language. Output only the rewritten text.' }, { id: 'prompt-engineer', name: 'Prompt Engineer', desc: 'Constructs optimal prompts', key: '2', prompt: 'Turn the text into a clear, well-structured prompt for an AI model. State the goal, context, constraints and the expected output format. Output only the prompt.' }, ]; function transforms() { const st = S.settings, tf = S.transforms; const keyBadge = k => `<span class="kbd"><span>Ctrl</span><span>Alt</span><span>${esc(k)}</span></span>`; const dots = [['S', 18, 4], ['G', 108, 4], ['in', 200, 4], ['N', 62, 40], ['T', 152, 40], ['Z', 18, 76], ['M', 108, 76], ['C', 200, 76]]; return `<div class="page narrow"> <div class="page-head"><h1>Transforms <span class="beta">Beta</span></h1><div class="head-actions"> <span class="optin">Opt in <span title="Enables Ctrl+Alt+number on selected text in any app">${ic('info')}</span><button class="toggle ${st.transformsOptIn ? 'on' : ''}" data-toggle="transformsOptIn"></button></span> <span class="pill-hint"><span class="kbd"><span>Ctrl</span><span>Alt</span><span>1–9</span></span> on selected text</span></div></div> ${banner('b-trans', `<div class="app-dots">${dots.map(([t, x, y]) => `<span style="left:${x}px;top:${y}%">${t}</span>`).join('')}</div> <h2>Transform works anywhere you write</h2><p style="max-width:330px">Apply a Transform to rewrite, clean up, or restructure text after you dictate.</p> <button class="btn dark" data-act="tf-try">Try it out</button> <button class="btn" style="background:none;color:#fff" data-act="tf-how">How it works</button>`)} <div class="auto-row"><div class="grow"><h4>Auto Apply After Dictation</h4><p>Run a transform on everything you dictate, automatically.</p></div> <span class="muted" title="Runs after Auto cleanup and Style">${ic('info')}</span> <select class="select" data-set="autoTransform.id">${tf.map(t => `<option value="${t.id}" ${st.autoTransform?.id === t.id ? 'selected' : ''}>${esc(t.name)}</option>`).join('')}</select> <button class="toggle ${st.autoTransform?.enabled ? 'on' : ''}" data-toggle="autoTransform.enabled"></button></div> <div class="sec-head"><h2>My Transforms</h2><button data-act="tf-reset" style="display:flex;gap:8px;align-items:center">${ic('undo')}Reset to defaults</button><button class="btn" data-act="tf-edit">Create New</button></div> <div class="cards3">${tf.map(t => `<button class="tcard" data-act="tf-edit" data-id="${t.id}">${keyBadge(t.key || '–')}<h4>${esc(t.name)}</h4><p>${esc(t.desc || '')}</p></button>`).join('')} <button class="tcard" data-act="tf-edit"><span class="plus-dot">${ic('plus')}</span><h4>Create your own</h4><p>Write your own prompt</p></button></div></div>`; } // ---------------------------------------------------------------- assistant const modelName = id => (S.runtime.assistantModels || []).find(m => m.id === id)?.name || id || ''; const secs = ms => ms >= 60000 ? fmtDur(ms) : `${(ms / 1000).toFixed(1)} s`; function assistantPage() { const a = S.settings.assistant || {}, rt = S.runtime, running = rt.assistant === 'running'; const all = [...(S.commands || [])].sort((x, y) => y.ts - x.ts); const live = running && all[0] && !all[0].reply ? all[0] : null; const items = all.filter(c => matches('asst', c.text, c.reply)); const groups = []; items.forEach(c => { const d = fmtDay(c.ts); if (!groups.length || groups.at(-1).d !== d) groups.push({ d, xs: [] }); groups.at(-1).xs.push(c); }); const log = groups.map(g => `<div class="day"><span>${g.d}</span></div><div class="list asst-log">${g.xs.map(c => cmdRow(c, live?.id)).join('')}</div>`).join('');