`; } /* ── JPG via SVG (no external library) ─────── */ async function toJpgBlob(d){ const W = 820; const inner = `
${cardBody(d)}
`; const probe = document.createElement('div'); probe.setAttribute('aria-hidden', 'true'); probe.style.cssText = `position:fixed; left:-99999px; top:0; width:${W}px; pointer-events:none;`; probe.innerHTML = inner; document.body.appendChild(probe); await new Promise(r => requestAnimationFrame(r)); const page = probe.querySelector('.flove-page'); const H = Math.ceil((page ? page.getBoundingClientRect().height : probe.scrollHeight)) || 600; probe.remove(); const svg = `` + `` + `
${inner}
` + `
` + `
`; const svgBlob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }); const url = URL.createObjectURL(svgBlob); const img = await new Promise((res, rej) => { const i = new Image(); i.onload = () => res(i); i.onerror = () => rej(new Error('SVG render failed')); i.src = url; }); const scale = 2; const canvas = document.createElement('canvas'); canvas.width = W * scale; canvas.height = H * scale; const ctx = canvas.getContext('2d'); ctx.fillStyle = '#fbf6ff'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.drawImage(img, 0, 0, canvas.width, canvas.height); URL.revokeObjectURL(url); return await new Promise((res, rej) => { canvas.toBlob(b => b ? res(b) : rej(new Error('Canvas export failed (possibly tainted)')), 'image/jpeg', 0.92); }); } /* ── download helper ─────────────────────────────────────── */ function triggerDownload(filename, mime, data){ const blob = data instanceof Blob ? data : new Blob([data], { type: mime }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.style.display = 'none'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1500); } function safeName(/* s */){ /* All download filenames use the page basename (e.g. "flovyadvanced-one") — Marc's requirement: "Rename all downloads filenames to be pagename.fileextension only". The original signature took the document title; the arg is intentionally ignored. */ return (location.pathname.split('/').pop() || 'flove').replace(/\.html$/, ''); } /* ── tiny store-only ZIP (no deps) ───────────────────────── */ const CRC = (() => { const t = new Uint32Array(256); for (let i = 0; i < 256; i++){ let c = i; for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); t[i] = c >>> 0; } return t; })(); function crc32(buf){ let c = 0xFFFFFFFF; for (let i = 0; i < buf.length; i++) c = CRC[(c ^ buf[i]) & 0xFF] ^ (c >>> 8); return (c ^ 0xFFFFFFFF) >>> 0; } function makeZip(entries){ const locals = []; const central = []; let offset = 0; for (const e of entries){ const name = toBytes(e.name); const data = toBytes(e.data); const crc = crc32(data); const lh = new Uint8Array(30 + name.length); const ldv = new DataView(lh.buffer); ldv.setUint32(0, 0x04034b50, true); ldv.setUint16(4, 20, true); // version ldv.setUint16(6, 0x0800, true); // UTF-8 flag ldv.setUint16(8, 0, true); // method = store ldv.setUint16(10, 0, true); // mod time ldv.setUint16(12, 0x21, true); // mod date ldv.setUint32(14, crc, true); ldv.setUint32(18, data.length, true); ldv.setUint32(22, data.length, true); ldv.setUint16(26, name.length, true); ldv.setUint16(28, 0, true); lh.set(name, 30); locals.push(lh, data); const ch = new Uint8Array(46 + name.length); const cdv = new DataView(ch.buffer); cdv.setUint32(0, 0x02014b50, true); cdv.setUint16(4, 20, true); // version made by cdv.setUint16(6, 20, true); // version needed cdv.setUint16(8, 0x0800, true); cdv.setUint16(10, 0, true); cdv.setUint16(12, 0, true); cdv.setUint16(14, 0x21, true); cdv.setUint32(16, crc, true); cdv.setUint32(20, data.length, true); cdv.setUint32(24, data.length, true); cdv.setUint16(28, name.length, true); cdv.setUint16(30, 0, true); cdv.setUint16(32, 0, true); cdv.setUint16(34, 0, true); cdv.setUint16(36, 0, true); cdv.setUint32(38, 0, true); cdv.setUint32(42, offset, true); ch.set(name, 46); central.push(ch); offset += lh.length + data.length; } const cdSize = central.reduce((s, c) => s + c.length, 0); const eocd = new Uint8Array(22); const edv = new DataView(eocd.buffer); edv.setUint32(0, 0x06054b50, true); edv.setUint16(4, 0, true); edv.setUint16(6, 0, true); edv.setUint16(8, entries.length, true); edv.setUint16(10, entries.length, true); edv.setUint32(12, cdSize, true); edv.setUint32(16, offset, true); edv.setUint16(20, 0, true); return new Blob([...locals, ...central, eocd], { type: 'application/zip' }); } async function fetchBytes(url){ try { const r = await fetch(url, { cache: 'no-store' }); if (!r.ok) return null; return new Uint8Array(await r.arrayBuffer()); } catch(_){ return null; } } /* ── save dispatch ───────────────────────────────────────── */ async function save(format, rootEl){ const d = collect(rootEl); const base = safeName(d.title); switch (format){ case 'txt': return triggerDownload(`${base}.txt`, 'text/plain;charset=utf-8', toTxt(d)); case 'csv': return triggerDownload(`${base}.csv`, 'text/csv;charset=utf-8', toCsv(d)); case 'xml': return triggerDownload(`${base}.xml`, 'application/xml;charset=utf-8', toXml(d)); case 'json': return triggerDownload(`${base}.json`, 'application/json;charset=utf-8', toJson(d)); case 'html': return triggerDownload(`${base}.html`, 'text/html;charset=utf-8', toHtml(d)); case 'jpg': case 'jpeg': { const blob = await toJpgBlob(d); return triggerDownload(`${base}.jpg`, 'image/jpeg', blob); } case 'zip': { const files = [ { name: `${base}.txt`, data: toTxt(d) }, { name: `${base}.csv`, data: toCsv(d) }, { name: `${base}.xml`, data: toXml(d) }, { name: `${base}.json`, data: toJson(d) }, { name: `${base}.html`, data: toHtml(d) }, ]; const jpgBlob = await toJpgBlob(d).catch(() => null); if (jpgBlob) files.push({ name: `${base}.jpg`, data: new Uint8Array(await jpgBlob.arrayBuffer()) }); const pageName = (location.pathname.split('/').pop() || 'index.html') || 'index.html'; const pageBytes = await fetchBytes(pageName); if (pageBytes) files.push({ name: pageName, data: pageBytes }); const cssBytes = await fetchBytes('flove.css'); if (cssBytes) files.push({ name: 'flove.css', data: cssBytes }); const jsBytes = await fetchBytes('flove.js'); if (jsBytes) files.push({ name: 'flove.js', data: jsBytes }); const blob = makeZip(files); return triggerDownload(`${base}.zip`, 'application/zip', blob); } default: console.warn('[flove] unknown save format:', format); } } /* ── share ───────────────────────────────────────────────── */ async function share(rootEl, opts){ const d = collect(rootEl); const shareText = d.phrase || d.title; let jpgBlob = null; try { jpgBlob = await toJpgBlob(d); } catch(_){ /* tainted / unsupported — fall back below */ } if (!(opts && opts.forceMenu)){ if (jpgBlob && navigator.canShare){ const file = new File([jpgBlob], `${safeName(d.title)}.jpg`, { type: 'image/jpeg' }); if (navigator.canShare({ files: [file] })){ try { await navigator.share({ title: d.title, text: shareText, files: [file] }); return; } catch(_){ /* user canceled or unsupported — fall through to menu */ } } } if (navigator.share){ try { await navigator.share({ title: d.title, text: shareText, url: location.href }); return; } catch(_){} } } openShareMenu(d, jpgBlob, opts || {}); } /* Always opens the in-page share card overlay, skipping navigator.share. Useful for a dedicated "Mobile" button. */ async function shareMenu(rootEl, opts){ return share(rootEl, Object.assign({ forceMenu: true }, opts || {})); } function openShareMenu(d, jpgBlob, opts){ opts = opts || {}; document.getElementById('flove-share-menu')?.remove(); const overlay = document.createElement('div'); overlay.id = 'flove-share-menu'; overlay.setAttribute('role', 'dialog'); overlay.setAttribute('aria-modal', 'true'); overlay.setAttribute('aria-label', 'Share to'); overlay.style.cssText = ` position:fixed; inset:0; z-index:99999; background:rgba(20,15,30,.45); backdrop-filter: blur(4px); display:grid; place-items:end center; font: 15px/1.4 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; `; const text = encodeURIComponent(d.phrase || d.title); const url = encodeURIComponent(location.href); const subj = encodeURIComponent(d.title); // Hookable platform registry: host pages can replace or extend // window.flove.shareApps before clicking. Each entry can carry a // `href` string template, OR // `build({text,url,subj,data})` returning a string href. const registry = (window.flove && window.flove.shareApps) || defaultShareApps(); const apps = registry .filter(a => opts.mobile ? a.mobile !== false : true) .map(a => Object.assign({}, a, { href: typeof a.build === 'function' ? a.build({ text, url, subj, data: d }) : a.href, })); overlay.innerHTML = `

${opts.mobile ? '📱 Share on mobile' : 'Share'}

${opts.mobile ? 'Pick an app to forward the card.' : 'Pick an app to forward the jpg.'}

`; const grid = overlay.querySelector('[data-grid]'); apps.forEach(a => { const el = document.createElement('a'); el.href = a.href; el.target = '_blank'; el.rel = 'noopener noreferrer'; el.style.cssText = ` display:flex; flex-direction:column; align-items:center; gap:6px; padding:12px 8px; border-radius:14px; background:#f6f3fc; color:#1d1b22; text-decoration:none; font-size:12px; text-align:center; transition: transform .15s ease, background .15s ease;`; el.onmouseenter = () => { el.style.background = '#ece5f8'; }; el.onmouseleave = () => { el.style.background = '#f6f3fc'; }; el.innerHTML = `${a.emoji}${a.name}`; // On tap: download the full page being browsed, then let the // intent open (so the user can attach the file in the chosen app). el.addEventListener('click', () => { downloadCurrentPage(); }); grid.appendChild(el); }); overlay.addEventListener('click', (ev) => { if (ev.target === overlay){ overlay.remove(); return; } const act = ev.target.closest('[data-act]')?.dataset.act; if (!act) return; if (act === 'close'){ overlay.remove(); } else if (act === 'copy'){ const txt = `${d.title}\n\n${d.phrase}\n\n${location.href}`; navigator.clipboard?.writeText(txt); const b = ev.target.closest('[data-act]'); const old = b.textContent; b.textContent = '✓ Copied'; setTimeout(() => b.textContent = old, 1400); } else if (act === 'download'){ (async () => { try { const blob = jpgBlob || await toJpgBlob(d); triggerDownload(`${safeName(d.title)}.jpg`, 'image/jpeg', blob); } catch(e){ console.warn('[flove] jpg failed:', e); } })(); } }); document.addEventListener('keydown', function esc(e){ if (e.key === 'Escape'){ overlay.remove(); document.removeEventListener('keydown', esc); } }); document.body.appendChild(overlay); } /* Fetch the page that's being browsed and trigger a download. Used by the mobile share sheet so the user has the full file ready to attach in whichever app they pick. Quietly no-ops on fetch failure. */ let _pageDlT = 0; async function downloadCurrentPage(){ // Debounce — multiple taps in a row only emit one download. const now = Date.now(); if (now - _pageDlT < 1200) return; _pageDlT = now; try { const r = await fetch(location.href, { cache: 'no-store' }); if (!r.ok) return; const blob = await r.blob(); const path = (location.pathname.split('/').pop() || 'index.html') || 'index.html'; const name = /\./.test(path) ? path : (path + '.html'); triggerDownload(name, blob.type || 'text/html', blob); } catch(_){ /* offline / cross-origin / file:// — silent */ } } /* Default share-platforms registry. Hosts can mutate window.flove.shareApps to add/remove platforms — entries with `build(ctx)` get the encoded phrase/url/subject/data ready to slot into custom intents. */ function defaultShareApps(){ return [ { name: 'WhatsApp', emoji: '🟢', mobile: true, build: ({text,url}) => `https://wa.me/?text=${text}%20${url}` }, { name: 'Telegram', emoji: '✈️', mobile: true, build: ({text,url}) => `https://t.me/share/url?url=${url}&text=${text}` }, { name: 'Signal', emoji: '🔒', mobile: true, build: ({text}) => `https://signal.me/#p/${text}` }, { name: 'Messenger', emoji: '💬', mobile: true, build: ({url}) => `https://www.facebook.com/dialog/send?link=${url}&app_id=0&redirect_uri=${url}` }, { name: 'X / Twitter', emoji: '𝕏', mobile: true, build: ({text,url}) => `https://twitter.com/intent/tweet?text=${text}&url=${url}` }, { name: 'Threads', emoji: '@', mobile: true, build: ({text,url}) => `https://www.threads.net/intent/post?text=${text}%20${url}` }, { name: 'Bluesky', emoji: '🦋', mobile: true, build: ({text,url}) => `https://bsky.app/intent/compose?text=${text}%20${url}` }, { name: 'Mastodon', emoji: '🐘', mobile: false, build: ({text,url}) => `https://mastodonshare.com/?text=${text}&url=${url}` }, { name: 'Reddit', emoji: '👽', mobile: false, build: ({subj,url}) => `https://www.reddit.com/submit?title=${subj}&url=${url}` }, { name: 'LinkedIn', emoji: '💼', mobile: false, build: ({url}) => `https://www.linkedin.com/sharing/share-offsite/?url=${url}` }, { name: 'Email', emoji: '📧', mobile: true, build: ({subj,text,url}) => `mailto:?subject=${subj}&body=${text}%0A%0A${url}` }, { name: 'SMS', emoji: '💬', mobile: true, build: ({text,url}) => `sms:?&body=${text}%20${url}` }, ]; } // Publish/seed the registry early so hosts can mutate it (push/replace) before any share click. window.flove = window.flove || {}; if (!window.flove.shareApps) window.flove.shareApps = defaultShareApps(); function btnStyle(kind){ const ghost = kind === 'ghost'; return ` flex:1; padding:10px 12px; border-radius:12px; cursor:pointer; border:1px solid ${ghost ? 'rgba(0,0,0,.12)' : 'transparent'}; background:${ghost ? 'transparent' : 'linear-gradient(135deg,#9b5fff,#ffb44e)'}; color:${ghost ? '#1d1b22' : '#fff'}; font: 13px/1 ui-sans-serif, system-ui, sans-serif; letter-spacing:.02em;`; } /* ── view toggle ─────────────────────────────────────────── */ function setView(btn){ const view = btn.dataset.floveView; const group = btn.dataset.floveViewGroup || 'default'; const sel = btn.dataset.floveViewTarget; const target = (sel && document.querySelector(sel)) || btn.closest('[data-flove-root]') || document.body; const prefix = `flove-view--${group}--`; [...target.classList].forEach(c => { if (c.startsWith(prefix)) target.classList.remove(c); }); target.classList.add(prefix + view); target.dispatchEvent(new CustomEvent('flove:view', { detail: { view, group, target, button: btn }, bubbles: true, })); } /* ── click delegation ────────────────────────────────────── */ document.addEventListener('click', (ev) => { const saveEl = ev.target.closest('[data-flove-save]'); if (saveEl){ ev.preventDefault(); save(saveEl.dataset.floveSave, saveEl.closest('[data-flove-root]')); return; } const shareEl = ev.target.closest('[data-flove-share]'); if (shareEl){ ev.preventDefault(); share(shareEl.closest('[data-flove-root]')); return; } const shareMenuEl = ev.target.closest('[data-flove-share-menu]'); if (shareMenuEl){ ev.preventDefault(); const mode = (shareMenuEl.dataset.floveShareMenu || '').toLowerCase(); shareMenu(shareMenuEl.closest('[data-flove-root]'), { mobile: mode === 'mobile' }); return; } const viewEl = ev.target.closest('[data-flove-view]'); if (viewEl){ setView(viewEl); return; } }); /* ── public API ──────────────────────────────────────────── */ window.flove = Object.assign(window.flove || {}, { collect, save, share, shareMenu, formats: { toTxt, toCsv, toXml, toJson, toHtml, toJpgBlob }, makeZip, crc32, defaultShareApps, }); })(); /* ============================================================ SOUND DEPTH — Mini · Basic · Normal · Advanced · Super ============================================================ Driven by a single radio group `name="sound-level"` with the ids sound-mini / sound-basic / sound-normal / sound-advanced / sound-super. When #opt-sound is OFF nothing happens. The five levels are cumulative — every higher level keeps everything the lower ones add: • Mini preloaded click sounds (handled by the sound engine above). • Basic "lets you customize them" — the Sound switch already links to the Sounds section (step-5). No JS needed beyond making sure the engine is on; on Basic flove.speak() announces this once when the level is picked. • Normal reads section titles aloud whenever the visible section changes. Generic opt-in: any element with data-flove-speak="title" will be spoken when it becomes visible. Flovy's
h2s are picked up automatically by listening to its step radios. • Advanced reads the contents of fields (input / textarea / contenteditable) when they lose focus. Opt-out a field with data-flove-speak="off". • Super reads the texts a bot inserted (the magic / lovely / joy / wisdom textareas) whenever the active bot changes. Generic opt-in: data-flove-speak="bot" on any element. Public API: window.flove.speak(text) speak (gated by Sound switch) window.flove.getSoundLevel() 'mini' | 'basic' | 'normal' | … window.flove.setSoundLevel(n) programmatic level pick window.flove.soundLevelAtLeast('normal') This module makes no assumptions specific to flovy beyond looking for the standard ids — any other app can drop the same radio group in and opt in to titles/fields/bot texts via the data-attributes. ============================================================ */ (() => { 'use strict'; const LEVELS = ['mini', 'basic', 'normal', 'advanced', 'super']; function getSoundLevel(){ const r = document.querySelector('input[name="sound-level"]:checked'); return r ? r.id.replace(/^sound-/, '') : 'mini'; } function setSoundLevel(name){ const r = document.getElementById('sound-' + name); if (r){ r.checked = true; r.dispatchEvent(new Event('change', { bubbles: true })); } } function soundLevelAtLeast(name){ return LEVELS.indexOf(getSoundLevel()) >= LEVELS.indexOf(name); } function soundOn(){ const el = document.getElementById('opt-sound'); return !!(el && el.checked); } /* ── speech ───────────────────────────────────────────────── */ function speak(text, opts = {}){ if (!('speechSynthesis' in window)) return; const t = String(text == null ? '' : text).trim(); if (!t) return; if (!opts.force && !soundOn()) return; const u = new SpeechSynthesisUtterance(t); u.rate = opts.rate != null ? opts.rate : 1; u.pitch = opts.pitch != null ? opts.pitch : 1; u.volume = opts.volume != null ? opts.volume : 1; u.lang = opts.lang || document.documentElement.lang || 'en'; try { window.speechSynthesis.cancel(); window.speechSynthesis.speak(u); } catch(_){} } /* ── NORMAL: speak section titles when their section becomes visible ── */ // 1) Generic: IntersectionObserver on [data-flove-speak="title"]. const seenTitles = new WeakSet(); const titleObs = ('IntersectionObserver' in window) ? new IntersectionObserver((entries) => { if (!soundOn() || !soundLevelAtLeast('normal')) return; for (const e of entries){ if (!e.isIntersecting) continue; if (seenTitles.has(e.target)) continue; seenTitles.add(e.target); speak(e.target.textContent); } }, { threshold: 0.5 }) : null; function bindTitles(){ if (!titleObs) return; document.querySelectorAll('[data-flove-speak="title"]').forEach(el => { titleObs.observe(el); }); } // 2) Flovy-specific: react to step radio changes — speak the h2 of the // newly-visible .step-panel. The mapping is by nth-of-type: step-0 → // 1st panel, step-1 → 2nd, … const STEP_PANEL_SELECTOR = '.step-panel'; function flovyStepHeading(stepId){ // strip "step-" prefix; if not numeric, skip. const n = Number(stepId.replace(/^step-/, '')); if (!Number.isFinite(n)) return null; const panels = document.querySelectorAll(STEP_PANEL_SELECTOR); const panel = panels[n]; if (!panel) return null; const h = panel.querySelector('h2, h1, header'); return h ? h.textContent : null; } document.addEventListener('change', (ev) => { if (!soundOn() || !soundLevelAtLeast('normal')) return; const t = ev.target; if (!t || !t.id) return; if (t.matches('input[type="radio"][name="step"]')){ const txt = flovyStepHeading(t.id); if (txt) speak(txt); } }, true); /* ── ADVANCED: speak field contents on blur ──────────────── */ document.addEventListener('blur', (ev) => { if (!soundOn() || !soundLevelAtLeast('advanced')) return; const t = ev.target; if (!t || !t.matches) return; if (t.dataset && t.dataset.floveSpeak === 'off') return; let val = ''; if (t.matches('textarea, input[type="text"], input[type="search"], input[type="url"], input[type="email"]')){ val = t.value || ''; } else if (t.matches('[contenteditable="true"], [contenteditable=""]')){ val = t.textContent || ''; } else { return; } if (val.trim()) speak(val); }, true); /* ── SUPER: speak wizard-inserted texts when the bot changes ── */ // Generic opt-in: [data-flove-speak="bot"]. After any wizard-* radio // toggle, scan the page for visible wizard-text elements (matching the // generic data attr OR flovy's bot textarea classes) and speak the // first one. A short timeout gives the :has()/display CSS rules a // chance to flip visibility before we read the DOM. function isVisible(el){ if (!el) return false; if (el.offsetParent !== null) return true; const cs = el.ownerDocument.defaultView.getComputedStyle(el); return cs.display !== 'none' && cs.visibility !== 'hidden'; } function pickVisibleBotText(){ // 1) generic opt-in for (const el of document.querySelectorAll('[data-flove-speak="bot"]')){ if (isVisible(el)) return el.value || el.textContent; } // 2) flovy auto-discovery const SELECTORS = [ '.entry-field--main:not(.entry-field--extra) .entry-textarea--magic', '.entry-field--main:not(.entry-field--extra) .entry-textarea--lovely', '.entry-field--main:not(.entry-field--extra) .entry-textarea--lovely-1', '.entry-field--main:not(.entry-field--extra) .entry-textarea--lovely-2', '.entry-field--main:not(.entry-field--extra) .entry-textarea--lovely-3', '.entry-field--main:not(.entry-field--extra) .entry-textarea--lovely-4', '.entry-field--main:not(.entry-field--extra) .entry-textarea--lovely-5', '.entry-field--main:not(.entry-field--extra) .entry-textarea--joy', '.entry-field--main:not(.entry-field--extra) .entry-textarea--joy-1', '.entry-field--main:not(.entry-field--extra) .entry-textarea--joy-2', '.entry-field--main:not(.entry-field--extra) .entry-textarea--joy-3', '.entry-field--main:not(.entry-field--extra) .entry-textarea--joy-4', '.entry-field--main:not(.entry-field--extra) .entry-textarea--joy-5', '.entry-field--main:not(.entry-field--extra) .entry-textarea--wisdom', '.entry-field--main:not(.entry-field--extra) .entry-textarea--wisdom-1', '.entry-field--main:not(.entry-field--extra) .entry-textarea--wisdom-2', '.entry-field--main:not(.entry-field--extra) .entry-textarea--wisdom-3', '.entry-field--main:not(.entry-field--extra) .entry-textarea--wisdom-4', '.entry-field--main:not(.entry-field--extra) .entry-textarea--wisdom-5', ]; for (const sel of SELECTORS){ const el = document.querySelector(sel); if (el && isVisible(el)){ return el.value; } } return ''; } document.addEventListener('change', (ev) => { if (!soundOn() || !soundLevelAtLeast('super')) return; const t = ev.target; if (!t || !t.name) return; if (!/^wizard-choice(-|$)|^wizard-\d+-phrase$|^wizard-/.test(t.name)) return; setTimeout(() => { const txt = pickVisibleBotText(); if (txt) speak(txt); }, 40); }, true); /* ── speak a one-shot label when sound-level itself changes ── */ document.addEventListener('change', (ev) => { const t = ev.target; if (!t || !t.matches || !t.matches('input[name="sound-level"]')) return; if (!soundOn()) return; const label = t.id.replace(/^sound-/, ''); const map = { mini: 'Sound: Mini · clicks only', basic: 'Sound: Basic · customize in Sounds', normal: 'Sound: Normal · titles read aloud', advanced: 'Sound: Advanced · fields read aloud', super: 'Sound: Super · bot texts read aloud', }; speak(map[label] || ('Sound level ' + label)); }); /* ── init ───────────────────────────────────────────────── */ if (document.readyState === 'loading'){ document.addEventListener('DOMContentLoaded', bindTitles, { once: true }); } else { bindTitles(); } // Pick up dynamically added titles too. if ('MutationObserver' in window){ new MutationObserver(() => bindTitles()).observe(document.documentElement, { childList: true, subtree: true, }); } /* ── public API ─────────────────────────────────────────── */ window.flove = Object.assign(window.flove || {}, { speak, getSoundLevel, setSoundLevel, soundLevelAtLeast, }); })(); /* ============================================================ flove.wizard · wizard-suggestion text injection helpers ============================================================ Shared by every app that has a "magic / bot" suggestion button wired to a
Behavior • [data-wizard-magic] toggles a seed (default text) into the target. Picking a bot below replaces the seed. Toggling magic OFF removes only the seed — anything the user typed before / after stays. • [data-bot=""] cycles through that bot's N texts (default 5). Sixth click clears it. Bot picks are mutually exclusive within the same row — clicking another bot replaces the inserted text in place. • [data-wizard-clear] collapses the bot row: removes ONLY the wizard-inserted text, resets cycle counters, clears .is-on highlights, and turns the magic toggle off. Same contract as offer.html — the user's typed content is never touched on collapse. Texts are looked up in this order: 1. data-wizard-texts on the row — JSON: { lovely:[..], joy:[..], wisdom:[..] } and optional { magic: "seed text" }. 2. window.flove.wizard.packs[] — the row can carry data-wizard-pack="" to pick a named pack. 3. window.flove.wizard.packs.default — built-in fallback. ============================================================ */ (() => { 'use strict'; const counters = new WeakMap(); // row → { wizard: count } const magicState = new WeakMap(); // row → boolean const DEFAULT_PACK = { magic: "✨ Something shines here, something new being born.", lovely: [ "Dear reader, with care and respect, I share this for your consideration.", "May these words land softly, and may they meet you where you are.", "I hold what you say with both hands; nothing here will be rushed.", "If a phrase feels heavy, set it down; we can return to it when you're ready.", "Thank you for trusting this space with what matters to you.", ], joy: [ "Hey! What if we do it together and have a blast? 🎈", "Imagine the room lighting up — that's where this is heading. ✨", "Bring snacks, bring friends, bring whatever makes you grin. 🥳", "We'll figure out the small stuff while we dance the big stuff. 💃", "Mark the calendar in colours — this one's going to be remembered. 🎉", ], wisdom: [ "Where the air leans and the silence writes, there beats what has no name yet.", "The river does not argue with the stone; it remembers the long way home.", "Listen for the word the room is trying to say through you.", "What is asked of you now is older than the asking — older, and quieter.", "When you cannot see the path, sit. The path is also resting.", ], }; function rowOf(el){ return el && el.closest('[data-wizard-target], .wizard-row'); } function targetOf(row){ if (!row) return null; const sel = row.dataset.botTarget; if (sel){ try { return document.querySelector(sel); } catch(_){ return document.getElementById(sel.replace(/^#/, '')); } } return null; } function packFor(row){ // 1) inline JSON if (row.dataset.botTexts){ try { return JSON.parse(row.dataset.botTexts); } catch(_){} } // 2) named pack const packs = (window.flove && window.flove.wizard && window.flove.wizard.packs) || {}; const id = row.dataset.botPack || 'default'; return packs[id] || packs.default || DEFAULT_PACK; } function getCount(row, bot){ const c = counters.get(row) || {}; return c[bot] || 0; } function setCount(row, bot, n){ const c = counters.get(row) || {}; c[bot] = n; counters.set(row, c); } function resetCounts(row){ counters.set(row, {}); } function siblings(row){ return [...row.querySelectorAll('[data-bot], [data-wizard-magic]')]; } function clearIsOn(row){ siblings(row).forEach(b => b.classList.remove('is-on')); } function onMagicClick(btn){ const row = rowOf(btn); if (!row) return; const ta = targetOf(row); if (!ta) return; const pack = packFor(row); const on = !magicState.get(row); magicState.set(row, on); btn.classList.toggle('is-on', on); btn.setAttribute('aria-pressed', on ? 'true' : 'false'); if (on){ window.flove.wizard.inject(ta, pack.magic || "✨"); // bot picks reset to 0 since magic is the active text resetCounts(row); row.querySelectorAll('[data-bot]').forEach(b => b.classList.remove('is-on')); } else { window.flove.wizard.clear(ta); resetCounts(row); clearIsOn(row); } } function onBotClick(btn){ const row = rowOf(btn); if (!row) return; const ta = targetOf(row); if (!ta) return; const bot = btn.dataset.bot; const pack = packFor(row); const list = (pack && pack[bot]) || []; if (!list.length){ // no texts — just toggle highlight and skip const wasOn = btn.classList.contains('is-on'); clearIsOn(row); btn.classList.toggle('is-on', !wasOn); return; } const next = (getCount(row, bot) + 1) % (list.length + 1); // clear cycle counters for the other bots — only one is "on" at a time const c = {}; c[bot] = next; counters.set(row, c); clearIsOn(row); if (next === 0){ window.flove.wizard.clear(ta); magicState.set(row, false); } else { window.flove.wizard.inject(ta, list[next - 1]); btn.classList.add('is-on'); magicState.set(row, false); const magic = row.querySelector('[data-wizard-magic]'); if (magic){ magic.classList.remove('is-on'); magic.setAttribute('aria-pressed', 'false'); } } } function onClearClick(btn){ const row = rowOf(btn); if (!row) return; const ta = targetOf(row); if (ta) window.flove.wizard.clear(ta); resetCounts(row); magicState.set(row, false); clearIsOn(row); const magic = row.querySelector('[data-wizard-magic]'); if (magic) magic.setAttribute('aria-pressed', 'false'); } document.addEventListener('click', (ev) => { const magic = ev.target.closest('[data-wizard-magic]'); if (magic){ onMagicClick(magic); return; } const clear = ev.target.closest('[data-wizard-clear]'); if (clear){ onClearClick(clear); return; } const pick = ev.target.closest('[data-bot]'); if (pick && rowOf(pick)){ onBotClick(pick); return; } }); // Expose the packs registry so apps can extend it. window.flove = window.flove || {}; window.flove.wizard = window.flove.wizard || {}; window.flove.wizard.packs = Object.assign( { default: DEFAULT_PACK }, window.flove.wizard.packs || {} ); })(); /* ============================================================ flove.resume · declarative resume / summary buttons ============================================================ Wires the Save / Share / Publish / Copy / Print / Insight / View / Magic-toggle controls of a resume section via data attributes — no inline JS required. • data-flove-save="" Save dispatch (already wired in the Summary Actions module). Accepts a list (space- or comma-separated) — opens a small "pick a format" menu when more than one is given. Format "bundle" is an alias of "zip". Format "md" exports Markdown. • data-flove-share Share dispatch (already wired). • data-flove-copy Copies the phrase (or full snapshot text) to clipboard and emits a "flove:copied" CustomEvent. • data-flove-print window.print(). • data-flove-publish[=""] Dispatches a "flove:publish" CustomEvent. Detail carries the collected snapshot, the target platform (if any), and any schedule fields read from sibling .pub-cal-*, .pub-hh, .pub-mm, .pub-target selects. No network call is made — the host (or a future 0asis hook) listens and routes. Falls back to a toast + clipboard copy. • data-flove-insight-cycle A button group whose children carry data-flove-insight="". Clicking the parent cycles to the next child's text — useful when CSS-only cycling isn't enough. (flovy.html's CSS-only cycle keeps working without this attribute.) • data-flove-magic Toggles the .is-magic class on the resume root, so CSS can swap between the default and the magicked clauses. ============================================================ */ (() => { 'use strict'; function toast(msg){ let el = document.getElementById('flove-toast'); if (!el){ el = document.createElement('div'); el.id = 'flove-toast'; el.style.cssText = ` position:fixed; left:50%; bottom:24px; transform:translateX(-50%) translateY(12px); background:#1d1b22; color:#fff; padding:10px 16px; border-radius:99px; font: 13px/1.2 ui-sans-serif, system-ui, sans-serif; opacity:0; transition: opacity .2s, transform .2s; z-index: 99999; pointer-events:none; `; document.body.appendChild(el); } el.textContent = msg; requestAnimationFrame(() => { el.style.opacity = '1'; el.style.transform = 'translateX(-50%) translateY(0)'; }); clearTimeout(toast._t); toast._t = setTimeout(() => { el.style.opacity = '0'; el.style.transform = 'translateX(-50%) translateY(12px)'; }, 1800); } function escMd(s){ return String(s).replace(/([*_`])/g, '\\$1'); } function toMd(d){ const head = `# ${d.title}\n`; const ph = d.phrase ? `\n> ${d.phrase}\n` : ''; const list = d.fields.length ? '\n' + d.fields.map(f => `- **${escMd(f.label)}**: ${escMd(f.value)}`).join('\n') + '\n' : ''; return head + ph + list + `\n— flove · ${d.when}\n`; } // Register .md as a save format on top of the existing save dispatch. // We re-use the existing handler via a tiny shim: hook clicks on // [data-flove-save] that name "md" or "bundle" BEFORE the older // delegated listener (capture phase) and let everything else through. document.addEventListener('click', (ev) => { const el = ev.target.closest('[data-flove-save]'); if (!el) return; const raw = (el.dataset.floveSave || '').trim(); if (!raw) return; const fmts = raw.split(/[\s,]+/).filter(Boolean); // Multi-format → small picker (we don't intercept single-format // calls; the existing dispatch handles them via the default code path). if (fmts.length > 1){ ev.preventDefault(); ev.stopImmediatePropagation(); openFormatPicker(el, fmts); return; } const fmt = fmts[0].toLowerCase(); if (fmt === 'md'){ ev.preventDefault(); ev.stopImmediatePropagation(); const d = window.flove.collect(el.closest('[data-flove-root]') || undefined); const base = (d.title || 'flove').trim().replace(/\s+/g, '_').replace(/[^\w.\-]+/g, '') || 'flove'; const blob = new Blob([toMd(d)], { type: 'text/markdown;charset=utf-8' }); triggerDownload(`${base}.md`, blob); return; } if (fmt === 'bundle'){ ev.preventDefault(); ev.stopImmediatePropagation(); // forward to the existing "zip" handler window.flove.save('zip', el.closest('[data-flove-root]') || undefined); return; } // any other single fmt: leave it to the older listener. }, true); function triggerDownload(filename, blob){ const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.style.display = 'none'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1500); } function openFormatPicker(anchor, fmts){ document.getElementById('flove-fmt-picker')?.remove(); const menu = document.createElement('div'); menu.id = 'flove-fmt-picker'; menu.setAttribute('role', 'menu'); const r = anchor.getBoundingClientRect(); menu.style.cssText = ` position:fixed; left:${Math.max(8, Math.min(window.innerWidth - 220, r.left))}px; top:${r.bottom + 6}px; background:#fff; color:#1d1b22; border:1px solid rgba(0,0,0,.08); border-radius:12px; padding:6px; box-shadow: 0 12px 32px -16px rgba(0,0,0,.3); font: 13px/1.2 ui-sans-serif, system-ui, sans-serif; z-index: 99999; display:flex; flex-wrap:wrap; gap:4px; max-width: 260px; `; fmts.forEach(f => { const b = document.createElement('button'); b.type = 'button'; b.textContent = f.toUpperCase(); b.style.cssText = ` appearance:none; border:0; background:#f6f3fc; color:#1d1b22; padding:6px 10px; border-radius:8px; cursor:pointer; font:inherit;`; b.addEventListener('click', () => { menu.remove(); // re-fire as a single-format save click on the same root const root = anchor.closest('[data-flove-root]') || undefined; const lower = f.toLowerCase(); if (lower === 'md'){ const d = window.flove.collect(root); /* Filename = page basename (see safeName note above). */ const base = (location.pathname.split('/').pop() || 'flove').replace(/\.html$/, ''); triggerDownload(`${base}.md`, new Blob([toMd(d)], { type: 'text/markdown;charset=utf-8' })); } else if (lower === 'bundle'){ window.flove.save('zip', root); } else { window.flove.save(lower, root); } }); menu.appendChild(b); }); const close = (e) => { if (!menu.contains(e.target)){ menu.remove(); document.removeEventListener('click', close, true); } }; setTimeout(() => document.addEventListener('click', close, true), 0); document.body.appendChild(menu); } /* ── Copy ───────────────────────────────────────────────── */ function asPlainText(d){ const parts = [d.title, '', d.phrase, ''].filter(s => s != null); d.fields.forEach(f => parts.push(`${f.label}: ${f.value}`)); parts.push('', `— flove · ${d.when}`); return parts.join('\n'); } document.addEventListener('click', (ev) => { const el = ev.target.closest('[data-flove-copy]'); if (!el) return; ev.preventDefault(); const d = window.flove.collect(el.closest('[data-flove-root]') || undefined); const mode = el.dataset.floveCopy || 'all'; const txt = mode === 'phrase' ? (d.phrase || d.title || '') : asPlainText(d); const done = () => { toast('Copied'); el.dispatchEvent(new CustomEvent('flove:copied', { detail: { text: txt, data: d }, bubbles: true })); }; if (navigator.clipboard && navigator.clipboard.writeText){ navigator.clipboard.writeText(txt).then(done, () => toast("Couldn't copy")); } else { const ta = document.createElement('textarea'); ta.value = txt; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); done(); } catch(_){ toast("Couldn't copy"); } ta.remove(); } }); /* ── Print ──────────────────────────────────────────────── */ document.addEventListener('click', (ev) => { const el = ev.target.closest('[data-flove-print]'); if (!el) return; ev.preventDefault(); window.print(); }); /* ── Publish — fires CustomEvent, falls back to a toast ── */ function pickedPlatforms(root){ // look for radios/checkboxes whose id starts with "pub-plat-" return [...(root || document).querySelectorAll('input[id^="pub-plat-"]:checked')] .map(el => el.id.replace(/^pub-plat-/, '')); } function readSchedule(root){ const r = root || document; const get = sel => { const el = r.querySelector(sel); return el ? el.value : ''; }; const d = get('.pub-cal-d'), m = get('.pub-cal-m'), y = get('.pub-cal-y'); const hh = get('.pub-hh'), mm = get('.pub-mm'); const audience = get('.pub-target'); return { date: (y && m && d) ? `${y}-${String(m).padStart(2,'0')}-${String(d).padStart(2,'0')}` : '', time: (hh && mm) ? `${hh}:${mm}` : '', audience }; } document.addEventListener('click', (ev) => { const el = ev.target.closest('[data-flove-publish]'); if (!el) return; // Inside a publish-cycle label-for-radio, only act when the cycle is open. // We let the original click happen too (so the radio toggles via CSS). const root = el.closest('[data-flove-root]') || undefined; const d = window.flove.collect(root); const platforms = pickedPlatforms(root); const schedule = readSchedule(root); const explicit = el.dataset.flovePublish && el.dataset.flovePublish !== 'go' ? el.dataset.flovePublish : null; if (explicit) platforms.unshift(explicit); const detail = { data: d, platforms, schedule, button: el }; const fired = el.dispatchEvent(new CustomEvent('flove:publish', { detail, bubbles: true, cancelable: true, })); // If no listener cancels the default, fall back to a toast + clipboard. if (fired){ const where = platforms.length ? platforms.join(', ') : 'somewhere later'; const when = schedule.date && schedule.time ? `${schedule.date} ${schedule.time}` : 'now'; const text = asPlainText(d) + `\n\n[Publish → ${where} · ${when}]`; if (navigator.clipboard && navigator.clipboard.writeText){ navigator.clipboard.writeText(text).catch(() => {}); } toast(platforms.length ? `Queued · ${where}` : 'Publish queued'); } }); /* ── Insight cycle (JS fallback for CSS-only stepper) ──── */ document.addEventListener('click', (ev) => { const wrap = ev.target.closest('[data-flove-insight-cycle]'); if (!wrap) return; const steps = [...wrap.querySelectorAll('[data-flove-insight]')]; if (!steps.length) return; let i = Number(wrap.dataset.floveInsightIndex || -1); i = (i + 1) % steps.length; wrap.dataset.floveInsightIndex = String(i); steps.forEach((s, n) => s.classList.toggle('is-on', n === i)); wrap.dispatchEvent(new CustomEvent('flove:insight', { detail: { index: i, text: steps[i].dataset.floveInsight, step: steps[i] }, bubbles: true, })); }); /* ── Magic toggle on the resume root ───────────────────── */ document.addEventListener('click', (ev) => { const el = ev.target.closest('[data-flove-magic]'); if (!el) return; // Only act if it's a real button (avoid hijacking