From 7770149392041c5b216137c1b3b995a050eac129 Mon Sep 17 00:00:00 2001 From: Chenyu Li Date: Mon, 30 Mar 2026 12:43:54 -0700 Subject: [PATCH 1/4] Add virtual chat demo --- pages/demos/index.html | 5 + pages/demos/virtual-chat.html | 91 ++++++++ pages/demos/virtual-chat.ts | 402 ++++++++++++++++++++++++++++++++++ 3 files changed, 498 insertions(+) create mode 100644 pages/demos/virtual-chat.html create mode 100644 pages/demos/virtual-chat.ts diff --git a/pages/demos/index.html b/pages/demos/index.html index a15f4efd..3dc6039a 100644 --- a/pages/demos/index.html +++ b/pages/demos/index.html @@ -136,6 +136,11 @@

Rich Text

Masonry

A text-card occlusion demo where height prediction comes from Pretext instead of DOM reads.

+ + +

Virtual Chat

+

5,000 chat messages virtualized with live DOM vs Pretext measurement cost, toggleable with width and font-size sliders.

+
diff --git a/pages/demos/virtual-chat.html b/pages/demos/virtual-chat.html new file mode 100644 index 00000000..7a99c200 --- /dev/null +++ b/pages/demos/virtual-chat.html @@ -0,0 +1,91 @@ + + + + + +Virtual Chat β€” DOM vs Pretext + + + +
+
+ DOM + + Pretext +
+
+ + + 600px +
+
+ + + 15px +
+ β€” +
+
+
+
+ + + diff --git a/pages/demos/virtual-chat.ts b/pages/demos/virtual-chat.ts new file mode 100644 index 00000000..640813ae --- /dev/null +++ b/pages/demos/virtual-chat.ts @@ -0,0 +1,402 @@ +import { prepare, layout, type PreparedText } from '../../src/layout.ts' + +// --- Data --- + +const SNIPPETS: readonly string[] = [ + 'hey', 'hi!', 'what\'s up', 'nm u?', 'lol', 'ok', 'sure', 'sounds good', 'haha', 'πŸ‘', + 'omg that\'s hilarious πŸ˜‚', 'wait what happened??', 'no way', 'seriously?', 'I can\'t believe it', + 'yeah I was thinking the same thing honestly', 'did you see that tweet about the new AI model?', + 'I just finished reading that book you recommended, it was actually really good', + 'btw do you know if the meeting tomorrow is at 10 or 11? I keep getting conflicting info from different people', + 'I\'ve been trying to fix this bug for three hours and I think I finally found it. Turns out it was a race condition in the event handler that only triggers when you resize the window while scrolling. Classic.', + 'The restaurant on 5th street has amazing ramen. We should go sometime this week if you\'re free. They close early on weekdays though so we\'d need to get there before 8.', + 'https://github.com/some/really-long-url/that-wraps?query=param&foo=bar', + 'Remember when we tried to deploy on Friday and everything broke? Good times πŸ™ƒ', + 'Can you review my PR when you get a chance? It\'s the one that refactors the auth middleware. Not urgent but would be nice to get it merged before the sprint ends.', + 'I think the API is returning stale data. The cache TTL might be too aggressive. Let me check the config... yeah it\'s set to 24h which seems way too long for user preferences.', + 'ζ˜₯ε€©εˆ°δΊ†οΌŒε€©ζ°”θΆŠζ₯θΆŠζš–ε’ŒδΊ†οΌδ½ ι‚£θΎΉζ€ŽδΉˆζ ·οΌŸ', + 'Ω…Ψ±Ψ­Ψ¨Ψ§! ΩƒΩŠΩ Ψ­Ψ§Ω„Ωƒ Ψ§Ω„ΩŠΩˆΩ…ΨŸ Ψ£ΨͺΩ…Ω†Ω‰ Ψ£Ω† ΩŠΩƒΩˆΩ† ΩŠΩˆΩ…Ωƒ Ψ¬Ω…ΩŠΩ„Ψ§', + 'γ“γ‚“γ«γ‘γ―οΌζœ€θΏ‘γ©γ†γ§γ™γ‹οΌŸζ–°γ—γ„γƒ—γƒ­γ‚Έγ‚§γ‚―γƒˆγ―γ†γΎγγ„γ£γ¦γ„γΎγ™γ‹οΌŸ', + 'The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog.', + 'πŸŽ‰πŸŽŠπŸ₯³ Happy birthday!!! πŸŽ‚πŸŽˆπŸŽ', + 'TL;DR: the whole backend needs to be rewritten because someone thought it was a good idea to store JSON in a TEXT column and parse it on every single request. Performance is abysmal. We need to migrate to a proper schema with indexed columns.', +] + +const SENT_COLORS: readonly string[] = ['#1a4a8a', '#2a4a6a', '#1a3a6a', '#2a3a8a', '#1a4a7a'] +const RECV_COLORS: readonly string[] = ['#2a2a2a', '#2a2a35', '#302a2a', '#2a302a', '#2d2a2a'] + +// --- Types --- + +type Message = { + text: string + sent: boolean + color: string + sizeOffset: number +} + +type DomCache = { + scrollEl: HTMLElement + contentEl: HTMLElement + timingEl: HTMLElement + modeToggle: HTMLInputElement + domLabel: Element + pretextLabel: Element + widthSlider: HTMLInputElement + widthValEl: HTMLElement + fontSlider: HTMLInputElement + fontValEl: HTMLElement +} + +// --- Constants --- + +const MSG_COUNT = 5000 +const BUBBLE_PAD_V = 16 // 8 + 8 px vertical padding on .bubble +const BUBBLE_PAD_H = 24 // 12 + 12 px horizontal padding on .bubble +const MSG_PAD_V = 6 // 3 + 3 px vertical padding on .msg +const MSG_PAD_H = 32 // 16 + 16 px horizontal padding on .msg +const OVERSCAN = 5 +const FONT_FAMILY = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' +const FPS_WINDOW_MS = 400 +const FPS_DISPLAY_MS = 1000 + +// --- Helpers --- + +function fontStr(size: number): string { + return `${size}px ${FONT_FAMILY}` +} + +function lineH(size: number): number { + return Math.round(size * 1.47) +} + +function maxBubbleContentW(chatWidth: number): number { + return Math.floor((chatWidth - MSG_PAD_H) * 0.75) - BUBBLE_PAD_H +} + +function seededRandom(seed: number): () => number { + return () => { seed = (seed * 16807) % 2147483647; return (seed - 1) / 2147483646 } +} + +function pick(arr: readonly T[], rand: () => number): T { + return arr[Math.floor(rand() * arr.length)]! +} + +function getRequiredElement(id: string): HTMLElement { + const el = document.getElementById(id) + if (!(el instanceof HTMLElement)) throw new Error(`#${id} not found`) + return el +} + +function getRequiredInput(id: string): HTMLInputElement { + const el = document.getElementById(id) + if (!(el instanceof HTMLInputElement)) throw new Error(`#${id} not found`) + return el +} + +function getRequiredBySelector(selector: string): Element { + const el = document.querySelector(selector) + if (el === null) throw new Error(`${selector} not found`) + return el +} + +// --- Message generation --- + +function generateMessages(): Message[] { + const rand = seededRandom(42) + const msgs: Message[] = [] + for (let i = 0; i < MSG_COUNT; i++) { + const partCount = Math.floor(rand() * 3) + 1 + let text = '' + for (let p = 0; p < partCount; p++) { + if (p > 0) text += ' ' + text += pick(SNIPPETS, rand) + } + const sent = rand() > 0.45 + msgs.push({ + text, + sent, + color: sent ? pick(SENT_COLORS, rand) : pick(RECV_COLORS, rand), + sizeOffset: Math.floor(rand() * 6) - 2, + }) + } + return msgs +} + +// --- Virtualizer --- + +function renderVisible( + dom: DomCache, + messages: Message[], + heights: Float64Array, + offsets: Float64Array, + totalH: number, + baseFontSize: number, + rendered: Map, +): void { + dom.contentEl.style.height = totalH + 'px' + const scrollTop = dom.scrollEl.scrollTop + const viewH = dom.scrollEl.clientHeight + + // Binary search for first visible message + let lo = 0 + let hi = MSG_COUNT - 1 + while (lo < hi) { + const mid = (lo + hi) >>> 1 + if (offsets[mid]! + heights[mid]! < scrollTop) lo = mid + 1 + else hi = mid + } + const first = Math.max(0, lo - OVERSCAN) + let last = first + while (last < MSG_COUNT - 1 && offsets[last]! < scrollTop + viewH) last++ + last = Math.min(MSG_COUNT - 1, last + OVERSCAN) + + // Evict off-screen elements + const visible = new Set() + for (let i = first; i <= last; i++) visible.add(i) + rendered.forEach((el, idx) => { + if (!visible.has(idx)) { el.remove(); rendered.delete(idx) } + }) + + // Create newly visible elements + const bubbleOuterW = maxBubbleContentW(dom.scrollEl.clientWidth) + BUBBLE_PAD_H + for (let i = first; i <= last; i++) { + if (rendered.has(i)) continue + const m = messages[i]! + const sz = baseFontSize + m.sizeOffset + const el = document.createElement('div') + el.className = `msg ${m.sent ? 'sent' : 'received'}` + el.style.top = offsets[i]! + 'px' + el.style.height = heights[i]! + 'px' + const bubble = document.createElement('div') + bubble.className = 'bubble' + bubble.style.cssText = + `max-width:${bubbleOuterW}px;` + + `background:${m.color};` + + `font:${fontStr(sz)};` + + `line-height:${lineH(sz)}px;` + + `color:${m.sent ? '#e8f0fe' : '#e0e0e0'};` + bubble.textContent = m.text + el.appendChild(bubble) + dom.contentEl.appendChild(el) + rendered.set(i, el) + } +} + +// --- Measurement: Pretext --- + +function measureWithPretext( + messages: Message[], + prepared: PreparedText[], + chatWidth: number, + baseFontSize: number, + heights: Float64Array, + offsets: Float64Array, +): { totalH: number; ms: number } { + const maxW = maxBubbleContentW(chatWidth) + const t0 = performance.now() + let offset = 0 + for (let i = 0; i < MSG_COUNT; i++) { + const sz = baseFontSize + messages[i]!.sizeOffset + const h = layout(prepared[i]!, maxW, lineH(sz)).height + BUBBLE_PAD_V + MSG_PAD_V + heights[i] = h + offsets[i] = offset + offset += h + } + return { totalH: offset, ms: performance.now() - t0 } +} + +// --- Measurement: DOM --- + +function measureWithDOM( + messages: Message[], + chatWidth: number, + baseFontSize: number, + heights: Float64Array, + offsets: Float64Array, +): { totalH: number; ms: number } { + const maxW = maxBubbleContentW(chatWidth) + const t0 = performance.now() + + const container = document.createElement('div') + container.style.cssText = `position:absolute;visibility:hidden;top:0;left:0;width:${chatWidth}px;` + const els: HTMLElement[] = new Array(MSG_COUNT) + + for (let i = 0; i < MSG_COUNT; i++) { + const m = messages[i]! + const sz = baseFontSize + m.sizeOffset + const el = document.createElement('div') + el.style.cssText = + `max-width:${maxW + BUBBLE_PAD_H}px;width:fit-content;` + + `font:${fontStr(sz)};line-height:${lineH(sz)}px;` + + `word-break:normal;overflow-wrap:break-word;white-space:normal;` + + `padding:8px 12px;box-sizing:border-box;` + el.textContent = m.text + container.appendChild(el) + els[i] = el + } + document.body.appendChild(container) + + let offset = 0 + for (let i = 0; i < MSG_COUNT; i++) { + const h = els[i]!.offsetHeight + MSG_PAD_V + heights[i] = h + offsets[i] = offset + offset += h + } + document.body.removeChild(container) + + return { totalH: offset, ms: performance.now() - t0 } +} + +// --- FPS counter --- + +function createFpsCounter() { + let frames = 0 + let windowStart = performance.now() + let display = '' + return { + tick(): void { + frames++ + const now = performance.now() + if (now - windowStart >= FPS_WINDOW_MS) { + display = `${Math.round(frames / ((now - windowStart) / 1000))} fps` + frames = 0 + windowStart = now + } + }, + get text(): string { return display }, + } +} + +// --- Boot --- + +const messages = generateMessages() + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot, { once: true }) +} else { + boot() +} + +function boot(): void { + const dom: DomCache = { + scrollEl: getRequiredElement('chat'), + contentEl: getRequiredElement('chat-content'), + timingEl: getRequiredElement('timing'), + modeToggle: getRequiredInput('mode-toggle'), + domLabel: getRequiredBySelector('.toggle-label.dom'), + pretextLabel: getRequiredBySelector('.toggle-label.pretext'), + widthSlider: getRequiredInput('width-slider'), + widthValEl: getRequiredElement('width-val'), + fontSlider: getRequiredInput('font-slider'), + fontValEl: getRequiredElement('font-val'), + } + + const st = { + baseFontSize: 15, + chatWidth: 600, + usePretext: false, + totalH: 0, + preparedFontSize: -1, + } + + const heights = new Float64Array(MSG_COUNT) + const offsets = new Float64Array(MSG_COUNT) + let prepared: PreparedText[] = [] + const rendered = new Map() + const fps = createFpsCounter() + let fpsResetTimer = 0 + let scheduledRaf: number | null = null + + dom.scrollEl.style.width = st.chatWidth + 'px' + + // --- Prepare --- + + function ensurePrepared(): number { + if (st.preparedFontSize === st.baseFontSize) return 0 + const t0 = performance.now() + prepared = new Array(MSG_COUNT) + for (let i = 0; i < MSG_COUNT; i++) { + const m = messages[i]! + prepared[i] = prepare(m.text, fontStr(st.baseFontSize + m.sizeOffset)) + } + st.preparedFontSize = st.baseFontSize + return performance.now() - t0 + } + + // --- Update cycle --- + + function fullUpdate(fontChanged: boolean): void { + let detail: string + + if (st.usePretext) { + const prepMs = fontChanged ? ensurePrepared() : 0 + const result = measureWithPretext(messages, prepared, st.chatWidth, st.baseFontSize, heights, offsets) + st.totalH = result.totalH + detail = fontChanged && prepMs > 0 + ? `${prepMs.toFixed(1)}ms prepare + ${result.ms.toFixed(1)}ms layout` + : `${result.ms.toFixed(1)}ms layout` + } else { + const result = measureWithDOM(messages, st.chatWidth, st.baseFontSize, heights, offsets) + st.totalH = result.totalH + detail = `${result.ms.toFixed(1)}ms` + } + + rendered.forEach(el => el.remove()) + rendered.clear() + renderVisible(dom, messages, heights, offsets, st.totalH, st.baseFontSize, rendered) + + fps.tick() + dom.timingEl.textContent = fps.text ? `${detail} Β· ${fps.text}` : detail + clearTimeout(fpsResetTimer) + fpsResetTimer = window.setTimeout(() => { dom.timingEl.textContent = detail }, FPS_DISPLAY_MS) + } + + function scheduleRender(): void { + if (scheduledRaf !== null) return + scheduledRaf = requestAnimationFrame(() => { + scheduledRaf = null + if (st.totalH === 0) return + renderVisible(dom, messages, heights, offsets, st.totalH, st.baseFontSize, rendered) + }) + } + + // --- Toggle --- + + function syncToggleUI(): void { + dom.domLabel.classList.toggle('active', !st.usePretext) + dom.pretextLabel.classList.toggle('active', st.usePretext) + dom.timingEl.classList.toggle('dom', !st.usePretext) + dom.timingEl.classList.toggle('pretext', st.usePretext) + } + + dom.modeToggle.addEventListener('change', () => { + st.usePretext = dom.modeToggle.checked + syncToggleUI() + const scrollPct = dom.scrollEl.scrollTop / (st.totalH - dom.scrollEl.clientHeight || 1) + fullUpdate(true) + dom.scrollEl.scrollTop = scrollPct * (st.totalH - dom.scrollEl.clientHeight || 1) + }) + + // --- Sliders --- + + dom.widthSlider.addEventListener('input', () => { + st.chatWidth = Number.parseInt(dom.widthSlider.value, 10) + dom.widthValEl.textContent = st.chatWidth + 'px' + dom.scrollEl.style.width = st.chatWidth + 'px' + fullUpdate(false) + }) + + dom.fontSlider.addEventListener('input', () => { + st.baseFontSize = Number.parseInt(dom.fontSlider.value, 10) + dom.fontValEl.textContent = st.baseFontSize + 'px' + fullUpdate(true) + }) + + dom.scrollEl.addEventListener('scroll', scheduleRender) + + // --- Init --- + + syncToggleUI() + document.fonts.ready.then(() => fullUpdate(true)) +} From d65d9b61590841b66d370d2b8e6d55eac61b0d2c Mon Sep 17 00:00:00 2001 From: Chenyu Li Date: Mon, 30 Mar 2026 13:07:12 -0700 Subject: [PATCH 2/4] include in build --- scripts/build-demo-site.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/build-demo-site.ts b/scripts/build-demo-site.ts index 832878b0..efd0aedb 100644 --- a/scripts/build-demo-site.ts +++ b/scripts/build-demo-site.ts @@ -13,6 +13,7 @@ const entrypoints = [ 'pages/demos/masonry/index.html', 'pages/demos/rich-note.html', 'pages/demos/variable-typographic-ascii.html', + 'pages/demos/virtual-chat.html', ] const result = Bun.spawnSync( @@ -38,6 +39,7 @@ const targets = [ { source: 'masonry/index.html', target: 'masonry/index.html' }, { source: 'rich-note.html', target: 'rich-note/index.html' }, { source: 'variable-typographic-ascii.html', target: 'variable-typographic-ascii/index.html' }, + { source: 'virtual-chat.html', target: 'virtual-chat/index.html' }, ] for (let index = 0; index < targets.length; index++) { From ac4168248c81419c58ddde1fd71acd10523e0132 Mon Sep 17 00:00:00 2001 From: Chenyu Li Date: Mon, 30 Mar 2026 13:29:03 -0700 Subject: [PATCH 3/4] responsive default --- pages/demos/virtual-chat.html | 12 ++++++------ pages/demos/virtual-chat.ts | 11 ++++++++++- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/pages/demos/virtual-chat.html b/pages/demos/virtual-chat.html index 7a99c200..3174a409 100644 --- a/pages/demos/virtual-chat.html +++ b/pages/demos/virtual-chat.html @@ -6,11 +6,11 @@ Virtual Chat β€” DOM vs Pretext -
+
DOM Pretext
-
+
+
600px
-
+
15px
+
β€”
diff --git a/pages/demos/virtual-chat.ts b/pages/demos/virtual-chat.ts index 0917d732..cfe86647 100644 --- a/pages/demos/virtual-chat.ts +++ b/pages/demos/virtual-chat.ts @@ -348,7 +348,7 @@ function boot(): void { } else { const result = measureWithDOM(messages, st.chatWidth, st.baseFontSize, heights, offsets) st.totalH = result.totalH - detail = `${result.ms.toFixed(1)}ms` + detail = `${result.ms.toFixed(1)}ms measure` } rendered.forEach(el => el.remove())