// Vocabulary cloud (2026-11-08), shared by danielrosehill/Fritter-Demo and Hebrish/Sabich-Demo (same file in both folders; keep them identical). // training takes drift left and right, sized by how many takes contain them and coloured by group. Hover or tap a // word to see a sentence it was recorded in. Data: terms.json, built by ASR-Training scripts/fritter_terms.py from // danielrosehill/Tech-Sentences meta/*.jsonl. The same file is copied to ai.danielrosehill.com (danielrosehilldotcom). // // import { mountCloud } from './cloud.js'; // mountCloud(document.getElementById('cloud'), await (await fetch('terms.json')).json()); // // Needs in the host page: an element to mount into. Styles are injected once (class prefix "fc-"). const GROUP_COLOURS = { 'AI & models': '#7c5cff', 'Speech & audio': '#ff7a45', 'Dev & data': '#1aa57a', 'Linux & self-hosting': '#2f8fe6', 'Smart home': '#e0a100', 'Hardware': '#e2457a', }; const CSS = ` .fc { position: relative; --fc-ink: currentColor; } .fc-chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 14px; } .fc-chip { font: inherit; font-size: .82rem; font-weight: 600; border-radius: 999px; padding: 4px 12px; cursor: pointer; border: 1.5px solid var(--c); background: transparent; color: inherit; transition: background .2s, color .2s; } .fc-chip[aria-pressed="true"] { background: var(--c); color: #fff; } .fc-chip .fc-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--c); margin-right: 6px; vertical-align: 1px; } .fc-chip[aria-pressed="true"] .fc-dot { background: #fff; } .fc-stage { position: relative; overflow: hidden; border-radius: 18px; padding: 10px 0; -webkit-mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent); mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent); } .fc-lane { display: flex; width: max-content; align-items: center; white-space: nowrap; padding: 3px 0; animation: fc-drift var(--dur) linear infinite; animation-direction: var(--dir); } .fc-stage:hover .fc-lane.fc-hold, .fc-lane.fc-hold { animation-play-state: paused; } @keyframes fc-drift { from { transform: translateX(0); } to { transform: translateX(-50%); } } .fc-w { font-weight: 650; letter-spacing: -.01em; margin: 0 .55em; cursor: pointer; color: var(--c); transition: opacity .3s, transform .2s, filter .3s; line-height: 1.15; border: 0; background: none; padding: 0; font-family: inherit; } .fc-w:hover, .fc-w:focus-visible, .fc-w.fc-on { transform: scale(1.18); outline: none; text-shadow: 0 0 18px color-mix(in srgb, var(--c) 55%, transparent); } .fc.fc-filtered .fc-w:not(.fc-in) { opacity: .12; filter: grayscale(1); } .fc-card { margin-top: 14px; border-radius: 14px; padding: 14px 16px; min-height: 92px; background: color-mix(in srgb, var(--c, #888) 10%, transparent); border: 1px solid color-mix(in srgb, var(--c, #888) 35%, transparent); transition: background .4s, border-color .4s; } .fc-card .fc-term { font-weight: 700; color: var(--c); font-size: 1.05rem; } .fc-card .fc-meta { font-size: .8rem; opacity: .7; margin-left: 8px; } .fc-card .fc-said { margin: 6px 0 0; font-size: 1.05rem; } .fc-card mark { background: color-mix(in srgb, var(--c) 25%, transparent); color: inherit; border-radius: 4px; padding: 0 3px; } .fc-foot { font-size: .8rem; opacity: .65; margin-top: 8px; } @media (prefers-reduced-motion: reduce) { .fc-lane { animation: none; flex-wrap: wrap; width: auto; white-space: normal; justify-content: center; } .fc-lane .fc-dup { display: none; } .fc-stage { -webkit-mask-image: none; mask-image: none; } }`; function injectCss() { if (document.getElementById('fc-css')) return; const s = document.createElement('style'); s.id = 'fc-css'; s.textContent = CSS; document.head.appendChild(s); } const esc = (s) => s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); const reEsc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); function shuffle(a, seed = 7) { let x = seed; const r = () => ((x = (x * 9301 + 49297) % 233280) / 233280); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(r() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } export function mountCloud(root, data, opts = {}) { injectCss(); const lanes = opts.lanes || (matchMedia('(max-width: 600px)').matches ? 6 : 8); const colours = { ...GROUP_COLOURS, ...(opts.colours || {}) }; const terms = data.terms.filter((t) => colours[t.g]); const max = Math.max(...terms.map((t) => t.n)), min = Math.min(...terms.map((t) => t.n)); const size = (n) => 0.95 + 1.45 * (Math.log(n) - Math.log(min)) / (Math.log(max) - Math.log(min) || 1); // rem root.classList.add('fc'); const groups = [...new Set(terms.map((t) => t.g))].sort((a, b) => Object.keys(colours).indexOf(a) - Object.keys(colours).indexOf(b)); const counts = Object.fromEntries(groups.map((g) => [g, terms.filter((t) => t.g === g).length])); root.innerHTML = `
${terms.length} of the ${data.distinct_terms.toLocaleString()} terms in ${data.rows.toLocaleString()} ${opts.noun || "recorded tech sentences"}, each with 3 or more takes. Bigger means more takes. Hover or tap a word.
`; const stage = root.querySelector('.fc-stage'), card = root.querySelector('.fc-card'); // Deal terms into lanes so each lane mixes big and small words. const order = shuffle([...terms]); const rows = Array.from({ length: lanes }, () => []); order.forEach((t, i) => rows[i % lanes].push(t)); const wordHtml = (t, dup) => ``; stage.innerHTML = rows.map((r, i) => `“${said}”
` : ''); root.querySelectorAll('.fc-w.fc-on').forEach((w) => w.classList.remove('fc-on')); if (el) root.querySelectorAll(`.fc-w[data-i="${el.dataset.i}"]`).forEach((w) => w.classList.add('fc-on')); } const pool = () => terms.filter((t) => t.s && (!filter || t.g === filter)); function tick() { const p = pool(); if (!p.length) return; const t = p[Math.floor(Math.random() * p.length)]; show(t, root.querySelector(`.fc-w[data-i="${terms.indexOf(t)}"]`)); } const startAuto = () => { clearInterval(auto); auto = setInterval(tick, opts.interval || 4200); }; stage.addEventListener('pointerover', (e) => { const w = e.target.closest('.fc-w'); if (!w) return; active = w; w.closest('.fc-lane').classList.add('fc-hold'); clearInterval(auto); show(terms[w.dataset.i], w); }); stage.addEventListener('pointerout', (e) => { const w = e.target.closest('.fc-w'); if (!w) return; w.closest('.fc-lane').classList.remove('fc-hold'); active = null; startAuto(); }); stage.addEventListener('click', (e) => { const w = e.target.closest('.fc-w'); if (w) { clearInterval(auto); show(terms[w.dataset.i], w); setTimeout(startAuto, 6000); } }); stage.addEventListener('focusin', (e) => { const w = e.target.closest('.fc-w'); if (w) { clearInterval(auto); show(terms[w.dataset.i], w); } }); root.querySelector('.fc-chips').addEventListener('click', (e) => { const c = e.target.closest('.fc-chip'); if (!c) return; filter = filter === c.dataset.g ? null : c.dataset.g; root.querySelectorAll('.fc-chip').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.g === filter))); root.classList.toggle('fc-filtered', !!filter); root.querySelectorAll('.fc-w').forEach((w) => w.classList.toggle('fc-in', !filter || terms[w.dataset.i].g === filter)); tick(); startAuto(); }); tick(); startAuto(); return { destroy() { clearInterval(auto); root.innerHTML = ''; } }; }