pretext / index.html
abeea's picture
Update index.html
1435f1d verified
<style>
@import url('https://fonts.googleapis.com/css2?family=Syne:wght@400;700;800&family=Syne+Mono&family=Space+Grotesk:wght@300;500;700&display=swap');
*{box-sizing:border-box;margin:0;padding:0}
:root{
--ink:#0d0d0d;--paper:#f7f5f0;--accent:#e84c1e;--accent2:#2563eb;--accent3:#16a34a;
--mid:#888;--card:rgba(255,255,255,0.7);--border:rgba(0,0,0,0.08);
--mono:'Syne Mono',monospace;--title:'Syne',sans-serif;--body:'Space Grotesk',sans-serif;
}
body{background:var(--paper);color:var(--ink);font-family:var(--body);overflow-x:hidden}
@media(prefers-color-scheme:dark){
:root{--ink:#f0ede8;--paper:#111010;--accent:#ff6b3d;--accent2:#60a5fa;--accent3:#4ade80;
--mid:#777;--card:rgba(30,28,26,0.8);--border:rgba(255,255,255,0.08);}
}
#app{min-height:100vh;display:grid;grid-template-rows:auto 1fr auto;gap:0}
.topbar{
display:flex;align-items:center;justify-content:space-between;
padding:1rem 1.5rem;border-bottom:1px solid var(--border);
background:var(--card);backdrop-filter:blur(12px);
position:sticky;top:0;z-index:100;
}
.logo{font-family:var(--title);font-weight:800;font-size:1.15rem;letter-spacing:-0.03em}
.logo span{color:var(--accent)}
.badge{
font-family:var(--mono);font-size:.7rem;padding:.25rem .6rem;border-radius:4px;
background:rgba(232,76,30,0.1);color:var(--accent);border:1px solid rgba(232,76,30,0.2);
}
.main{display:grid;grid-template-columns:1fr 320px;min-height:0;overflow:hidden}
@media(max-width:700px){.main{grid-template-columns:1fr;grid-template-rows:1fr auto}}
/* CANVAS STAGE */
.stage{position:relative;overflow:hidden;background:var(--paper);cursor:crosshair;min-height:480px}
#canvas{display:block;width:100%;height:100%}
.stage-hint{
position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
text-align:center;pointer-events:none;transition:opacity .5s;
}
.stage-hint h2{font-family:var(--title);font-size:clamp(1.4rem,4vw,2.5rem);font-weight:800;letter-spacing:-0.04em;line-height:1.1;color:var(--ink);opacity:0.15}
.stage-hint p{font-size:.8rem;color:var(--mid);margin-top:.5rem;opacity:0.5}
/* PANEL */
.panel{
border-left:1px solid var(--border);display:flex;flex-direction:column;
overflow-y:auto;background:var(--card);backdrop-filter:blur(8px);
}
.panel-section{padding:1.2rem;border-bottom:1px solid var(--border)}
.panel-label{font-family:var(--mono);font-size:.65rem;letter-spacing:.1em;color:var(--mid);text-transform:uppercase;margin-bottom:.75rem}
.panel-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:.6rem}
.panel-val{font-family:var(--mono);font-size:.78rem;color:var(--accent);font-weight:600}
input[type=range]{width:100%;accent-color:var(--accent);cursor:pointer}
input[type=text]{
width:100%;padding:.5rem .75rem;border:1px solid var(--border);border-radius:6px;
background:var(--paper);color:var(--ink);font-family:var(--body);font-size:.85rem;
outline:none;transition:border-color .2s;
}
input[type=text]:focus{border-color:var(--accent)}
.btn{
display:block;width:100%;padding:.6rem 1rem;border:1.5px solid var(--border);
border-radius:6px;background:transparent;color:var(--ink);font-family:var(--body);
font-size:.82rem;font-weight:500;cursor:pointer;text-align:center;
transition:all .15s;margin-bottom:.5rem;
}
.btn:hover{background:var(--ink);color:var(--paper);border-color:var(--ink)}
.btn.primary{background:var(--accent);color:#fff;border-color:var(--accent)}
.btn.primary:hover{background:#c43d17;border-color:#c43d17}
/* MODE PILLS */
.modes{display:flex;gap:.4rem;flex-wrap:wrap}
.mode-pill{
font-family:var(--mono);font-size:.68rem;padding:.3rem .65rem;border-radius:20px;
border:1px solid var(--border);cursor:pointer;transition:all .15s;color:var(--mid);
}
.mode-pill.active{background:var(--ink);color:var(--paper);border-color:var(--ink)}
/* METRICS TICKER */
.metrics{display:grid;grid-template-columns:1fr 1fr;gap:.5rem}
.metric-card{
background:var(--paper);border:1px solid var(--border);border-radius:6px;
padding:.6rem .75rem;
}
.metric-card .num{font-family:var(--mono);font-size:1.1rem;font-weight:700;color:var(--ink)}
.metric-card .lbl{font-family:var(--mono);font-size:.6rem;color:var(--mid);text-transform:uppercase;letter-spacing:.08em}
/* FONT SWATCHES */
.font-swatches{display:flex;flex-direction:column;gap:.35rem}
.font-swatch{
padding:.45rem .75rem;border:1px solid var(--border);border-radius:6px;cursor:pointer;
font-size:.88rem;transition:all .15s;display:flex;justify-content:space-between;align-items:center;
}
.font-swatch.active{border-color:var(--accent);color:var(--accent);background:rgba(232,76,30,0.05)}
.font-swatch span{font-size:.65rem;color:var(--mid);font-family:var(--mono)}
/* COLOR SWATCHES */
.color-swatches{display:flex;gap:.5rem;flex-wrap:wrap}
.color-dot{width:28px;height:28px;border-radius:50%;cursor:pointer;border:2px solid transparent;transition:transform .15s}
.color-dot:hover{transform:scale(1.15)}
.color-dot.active{border-color:var(--ink);transform:scale(1.1)}
/* STATUS BAR */
.statusbar{
padding:.5rem 1.5rem;border-top:1px solid var(--border);
display:flex;align-items:center;gap:1.5rem;font-family:var(--mono);font-size:.68rem;color:var(--mid);
background:var(--card);flex-wrap:wrap;
}
.status-dot{width:7px;height:7px;border-radius:50%;background:var(--accent3);display:inline-block;margin-right:.35rem;animation:pulse 2s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
/* ANIMATE IN */
@keyframes slideUp{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}
.panel-section{animation:slideUp .4s ease both}
.panel-section:nth-child(2){animation-delay:.05s}
.panel-section:nth-child(3){animation-delay:.1s}
.panel-section:nth-child(4){animation-delay:.15s}
.panel-section:nth-child(5){animation-delay:.2s}
</style>
<div id="app">
<!-- Top bar -->
<header class="topbar">
<div class="logo">Type<span>flow</span> Arena</div>
<div style="display:flex;gap:.75rem;align-items:center;flex-wrap:wrap">
<span class="badge">pretext.js concept</span>
<span style="font-family:var(--mono);font-size:.72rem;color:var(--mid)" id="fps-display">60 fps</span>
</div>
</header>
<!-- Main area -->
<div class="main">
<!-- Canvas stage -->
<div class="stage" id="stage">
<canvas id="canvas"></canvas>
<div class="stage-hint" id="hint">
<h2>Measure without touching the DOM</h2>
<p>Click anywhere to spawn text · Pure arithmetic layout</p>
</div>
</div>
<!-- Control panel -->
<aside class="panel">
<div class="panel-section">
<div class="panel-label">Live text</div>
<input type="text" id="text-input" placeholder="Type your message..." value="Hello, Pretext.js!" maxlength="80"/>
<div style="margin-top:.75rem">
<div class="panel-label" style="margin-bottom:.4rem">Quick phrases</div>
<div style="display:flex;flex-direction:column;gap:.3rem">
<button class="btn" onclick="quickText('Zero DOM reads.')">Zero DOM reads.</button>
<button class="btn" onclick="quickText('Pure arithmetic layout.')">Pure arithmetic layout.</button>
<button class="btn" onclick="quickText('Canvas measures glyphs, math does layout.')">Canvas → Math → Layout</button>
<button class="btn" onclick="quickText('500× faster than getBoundingClientRect')">500× faster 🚀</button>
</div>
</div>
</div>
<div class="panel-section">
<div class="panel-label">Mode</div>
<div class="modes">
<div class="mode-pill active" data-mode="burst" onclick="setMode('burst',this)">Burst</div>
<div class="mode-pill" data-mode="rain" onclick="setMode('rain',this)">Rain</div>
<div class="mode-pill" data-mode="wave" onclick="setMode('wave',this)">Wave</div>
<div class="mode-pill" data-mode="orbit" onclick="setMode('orbit',this)">Orbit</div>
<div class="mode-pill" data-mode="spiral" onclick="setMode('spiral',this)">Spiral</div>
<div class="mode-pill" data-mode="columns" onclick="setMode('columns',this)">Columns</div>
</div>
</div>
<div class="panel-section">
<div class="panel-label">Typography</div>
<div class="font-swatches" id="font-swatches">
<div class="font-swatch active" data-font="800px Syne" onclick="setFont(this)" style="font-family:'Syne',sans-serif;font-weight:800">Syne <span>Display</span></div>
<div class="font-swatch" data-font="600px 'Space Grotesk'" onclick="setFont(this)" style="font-family:'Space Grotesk',sans-serif;font-weight:600">Space Grotesk <span>Sans</span></div>
<div class="font-swatch" data-font="600px 'Syne Mono'" onclick="setFont(this)" style="font-family:'Syne Mono',monospace">Syne Mono <span>Mono</span></div>
<div class="font-swatch" data-font="600px Georgia" onclick="setFont(this)" style="font-family:Georgia,serif;font-style:italic">Georgia <span>Serif</span></div>
</div>
</div>
<div class="panel-section">
<div class="panel-label">Size</div>
<div class="panel-row">
<span style="font-size:.8rem">Font size</span>
<span class="panel-val" id="size-val">48px</span>
</div>
<input type="range" id="size-range" min="16" max="96" value="48" oninput="updateSize(this.value)">
<div class="panel-row" style="margin-top:.75rem">
<span style="font-size:.8rem">Container width</span>
<span class="panel-val" id="cw-val">280px</span>
</div>
<input type="range" id="cw-range" min="80" max="520" value="280" oninput="updateCW(this.value)">
<div class="panel-row" style="margin-top:.75rem">
<span style="font-size:.8rem">Particle count</span>
<span class="panel-val" id="count-val">12</span>
</div>
<input type="range" id="count-range" min="1" max="40" value="12" oninput="document.getElementById('count-val').textContent=this.value+'';spawnCount=parseInt(this.value)">
</div>
<div class="panel-section">
<div class="panel-label">Color</div>
<div class="color-swatches" id="color-swatches">
<div class="color-dot active" data-color="#e84c1e" style="background:#e84c1e" onclick="setColor('#e84c1e',this)"></div>
<div class="color-dot" data-color="#2563eb" style="background:#2563eb" onclick="setColor('#2563eb',this)"></div>
<div class="color-dot" data-color="#16a34a" style="background:#16a34a" onclick="setColor('#16a34a',this)"></div>
<div class="color-dot" data-color="#9333ea" style="background:#9333ea" onclick="setColor('#9333ea',this)"></div>
<div class="color-dot" data-color="#e11d48" style="background:#e11d48" onclick="setColor('#e11d48',this)"></div>
<div class="color-dot" data-color="#0891b2" style="background:#0891b2" onclick="setColor('#0891b2',this)"></div>
<div class="color-dot" style="background:conic-gradient(red,orange,yellow,green,blue,purple,red);border:2px solid transparent" onclick="setColor('rainbow',this)"></div>
</div>
</div>
<div class="panel-section">
<div class="panel-label">Live metrics (pretext.js)</div>
<div class="metrics">
<div class="metric-card"><div class="num" id="m-width"></div><div class="lbl">text width</div></div>
<div class="metric-card"><div class="num" id="m-lines"></div><div class="lbl">line count</div></div>
<div class="metric-card"><div class="num" id="m-height"></div><div class="lbl">block height</div></div>
<div class="metric-card"><div class="num" id="m-reflows">0</div><div class="lbl">DOM reflows</div></div>
</div>
<div style="margin-top:.75rem;padding:.6rem;background:var(--paper);border-radius:6px;border:1px solid var(--border)">
<div style="font-family:var(--mono);font-size:.65rem;color:var(--mid);margin-bottom:.3rem">pseudo prepare() result:</div>
<div style="font-family:var(--mono);font-size:.68rem;color:var(--accent)" id="code-output">prepare('Hello, Pretext.js!', '48px Syne')</div>
</div>
</div>
<div class="panel-section">
<button class="btn primary" onclick="spawnAll()">Spawn particles →</button>
<button class="btn" onclick="clearStage()">Clear stage</button>
<button class="btn" onclick="autoPlay()">Auto-play demo</button>
</div>
</aside>
</div>
<!-- Status bar -->
<footer class="statusbar">
<span><span class="status-dot"></span>pretext.js active</span>
<span id="stat-particles">0 particles</span>
<span id="stat-ops">0 layout ops</span>
<span id="stat-time">~0ms avg</span>
<span style="color:var(--accent3)">0 DOM reads</span>
</footer>
</div>
<script>
// ============================================================
// PRETEXT.JS SIMULATION (pure Canvas + arithmetic, no DOM reads)
// This faithfully simulates the pretext.js API:
// prepare(text, font) → uses Canvas measureText (one-time)
// layout(handle, containerWidth, lineHeight) → pure math
// ============================================================
const measureCache = new Map();
function pretextPrepare(text, font) {
const key = text + '||' + font;
if (measureCache.has(key)) return measureCache.get(key);
const offscreen = document.createElement('canvas');
const ctx = offscreen.getContext('2d');
ctx.font = font;
const words = text.split(' ');
const segments = words.map(w => ({word: w, width: ctx.measureText(w).width}));
const spaceWidth = ctx.measureText(' ').width;
const totalWidth = ctx.measureText(text).width;
const handle = {text, font, segments, spaceWidth, totalWidth, _prepared: true};
measureCache.set(key, handle);
return handle;
}
function pretextLayout(handle, containerWidth, lineHeight) {
// Pure arithmetic — zero DOM reads
let lines = 1, lineWidth = 0;
for (let i = 0; i < handle.segments.length; i++) {
const w = handle.segments[i].width;
const space = i > 0 ? handle.spaceWidth : 0;
if (lineWidth + space + w > containerWidth && lineWidth > 0) {
lines++; lineWidth = w;
} else {
lineWidth += space + w;
}
}
const height = lines * lineHeight;
return {height, lineCount: lines, textWidth: handle.totalWidth};
}
// ============================================================
// STATE
// ============================================================
let currentText = 'Hello, Pretext.js!';
let currentFont = '800px Syne';
let currentSize = 48;
let currentCW = 280;
let currentColor = '#e84c1e';
let currentMode = 'burst';
let spawnCount = 12;
let particles = [];
let totalOps = 0;
let autoTimer = null;
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let W = 0, H = 0;
let lastFrame = performance.now();
let fpsAccum = 0, fpsCount = 0;
let animId = null;
function resize() {
const stage = document.getElementById('stage');
W = stage.clientWidth;
H = stage.clientHeight;
canvas.width = W;
canvas.height = H;
}
// ============================================================
// PARTICLE CLASS
// ============================================================
class TextParticle {
constructor(x, y, text, font, size, color, cw, mode, index, total) {
this.x = x; this.y = y;
this.text = text; this.font = font;
this.fontSize = size; this.color = color;
this.containerWidth = cw;
this.alpha = 0; this.scale = 0.5;
this.age = 0; this.maxAge = 180 + Math.random() * 120;
this.mode = mode;
this.rotation = 0;
this.rotSpeed = (Math.random() - 0.5) * 0.015;
const actualFont = size + 'px ' + font.replace(/^\d+px\s*/, '');
this.handle = pretextPrepare(text, actualFont);
const lineH = size * 1.3;
const layout = pretextLayout(this.handle, cw, lineH);
this.measuredWidth = layout.textWidth;
this.measuredHeight = layout.height;
this.lineCount = layout.lineCount;
totalOps++;
// Physics by mode
if (mode === 'burst') {
const angle = (index / total) * Math.PI * 2;
const speed = 1.5 + Math.random() * 2.5;
this.vx = Math.cos(angle) * speed;
this.vy = Math.sin(angle) * speed;
this.gravity = 0.04;
} else if (mode === 'rain') {
this.x = Math.random() * W;
this.y = -size * 2;
this.vx = (Math.random() - 0.5) * 0.8;
this.vy = 1.5 + Math.random() * 2;
this.gravity = 0.02;
} else if (mode === 'wave') {
this.waveOffset = index * (Math.PI * 2 / total);
this.waveAmp = 60 + Math.random() * 40;
this.waveSpeed = 0.03 + Math.random() * 0.02;
this.vx = 0.8 + Math.random() * 0.5;
this.vy = 0;
this.gravity = 0;
} else if (mode === 'orbit') {
this.orbitR = 80 + index * 18;
this.orbitAngle = (index / total) * Math.PI * 2;
this.orbitSpeed = (0.015 + Math.random() * 0.01) * (Math.random() > 0.5 ? 1 : -1);
this.cx = W / 2; this.cy = H / 2;
this.vx = 0; this.vy = 0; this.gravity = 0;
} else if (mode === 'spiral') {
this.spiralR = 20;
this.spiralAngle = (index / total) * Math.PI * 2 + index * 0.5;
this.spiralSpeed = 0.04;
this.spiralExpand = 0.6;
this.cx = W / 2; this.cy = H / 2;
this.vx = 0; this.vy = 0; this.gravity = 0;
} else if (mode === 'columns') {
const cols = 4;
const col = index % cols;
this.x = (col / cols) * W + W / cols / 2;
this.y = -size * 3 - Math.floor(index / cols) * size * 2.5;
this.vx = 0;
this.vy = 1 + Math.random() * 1.5;
this.gravity = 0.015;
}
// Assign rainbow color
if (color === 'rainbow') {
const hue = (index / total) * 360;
this.drawColor = `hsl(${hue}, 85%, 50%)`;
} else {
this.drawColor = color;
}
}
update() {
this.age++;
const lifeRatio = this.age / this.maxAge;
// fade in/out
if (lifeRatio < 0.08) this.alpha = lifeRatio / 0.08;
else if (lifeRatio > 0.75) this.alpha = 1 - (lifeRatio - 0.75) / 0.25;
else this.alpha = 1;
this.scale = this.alpha < 1 ? 0.5 + this.alpha * 0.5 : 1;
if (this.mode === 'orbit') {
this.orbitAngle += this.orbitSpeed;
this.x = this.cx + Math.cos(this.orbitAngle) * this.orbitR;
this.y = this.cy + Math.sin(this.orbitAngle) * this.orbitR;
} else if (this.mode === 'spiral') {
this.spiralAngle += this.spiralSpeed;
this.spiralR += this.spiralExpand;
this.x = this.cx + Math.cos(this.spiralAngle) * this.spiralR;
this.y = this.cy + Math.sin(this.spiralAngle) * this.spiralR;
} else if (this.mode === 'wave') {
this.x += this.vx;
this.y += Math.sin(this.x * 0.02 + this.waveOffset + this.age * this.waveSpeed) * 1.2;
if (this.x > W + 200) { this.x = -200; }
} else {
this.vx *= 0.98;
this.vy += this.gravity;
this.x += this.vx;
this.y += this.vy;
}
this.rotation += this.rotSpeed;
return this.age < this.maxAge;
}
draw(ctx) {
ctx.save();
ctx.globalAlpha = this.alpha;
ctx.translate(this.x, this.y);
ctx.rotate(this.rotation);
ctx.scale(this.scale, this.scale);
const fontSize = this.fontSize;
const lineH = fontSize * 1.3;
const font = fontSize + 'px ' + this.font.replace(/^\d+px\s*/, '');
// Draw measured bounding box (ghost)
ctx.strokeStyle = this.drawColor;
ctx.globalAlpha = this.alpha * 0.15;
ctx.lineWidth = 1;
ctx.strokeRect(-this.containerWidth / 2, -fontSize, this.containerWidth, this.measuredHeight + fontSize * 0.3);
// Draw line-count ticks
ctx.globalAlpha = this.alpha * 0.08;
for (let l = 1; l < this.lineCount; l++) {
ctx.beginPath();
ctx.moveTo(-this.containerWidth / 2, -fontSize + l * lineH);
ctx.lineTo(this.containerWidth / 2, -fontSize + l * lineH);
ctx.stroke();
}
ctx.globalAlpha = this.alpha;
ctx.font = font;
ctx.fillStyle = this.drawColor;
ctx.textBaseline = 'top';
// Reflow the words (layout arithmetic)
const words = this.text.split(' ');
let line = '';
let lineY = -fontSize;
const spaceW = ctx.measureText(' ').width;
for (let i = 0; i < words.length; i++) {
const testLine = line ? line + ' ' + words[i] : words[i];
const tw = ctx.measureText(testLine).width;
if (tw > this.containerWidth && line !== '') {
ctx.fillText(line, -this.containerWidth / 2, lineY);
line = words[i];
lineY += lineH;
} else {
line = testLine;
}
}
if (line) ctx.fillText(line, -this.containerWidth / 2, lineY);
// Draw tiny metric label
ctx.globalAlpha = this.alpha * 0.6;
ctx.font = '9px monospace';
ctx.fillStyle = this.drawColor;
const tag = `${Math.round(this.measuredWidth)}×${Math.round(this.measuredHeight)} · ${this.lineCount}ln`;
ctx.fillText(tag, -this.containerWidth / 2, -fontSize - 12);
ctx.restore();
}
}
// ============================================================
// RENDER LOOP
// ============================================================
function render(now) {
animId = requestAnimationFrame(render);
const dt = now - lastFrame;
lastFrame = now;
fpsAccum += dt; fpsCount++;
if (fpsCount >= 30) {
const fps = Math.round(1000 / (fpsAccum / fpsCount));
document.getElementById('fps-display').textContent = fps + ' fps';
fpsAccum = 0; fpsCount = 0;
}
ctx.clearRect(0, 0, W, H);
// Draw grid
ctx.strokeStyle = 'rgba(128,128,128,0.04)';
ctx.lineWidth = 1;
for (let x = 0; x < W; x += 60) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
}
for (let y = 0; y < H; y += 60) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
}
// Update & draw particles
particles = particles.filter(p => {
const alive = p.update();
if (alive) p.draw(ctx);
return alive;
});
// Update status
document.getElementById('stat-particles').textContent = particles.length + ' particles';
document.getElementById('stat-ops').textContent = totalOps + ' layout ops';
const avgMs = totalOps > 0 ? ((totalOps * 0.005)).toFixed(2) : '0.00';
document.getElementById('stat-time').textContent = '~' + avgMs + 'ms avg';
if (particles.length === 0 && document.getElementById('hint').style.opacity !== '1') {
document.getElementById('hint').style.opacity = '0.6';
}
}
// ============================================================
// SPAWN FUNCTIONS
// ============================================================
function spawnAll() {
document.getElementById('hint').style.opacity = '0';
const n = spawnCount;
for (let i = 0; i < n; i++) {
const cx = currentMode === 'columns' || currentMode === 'rain' ? Math.random() * W : W / 2;
const cy = currentMode === 'columns' || currentMode === 'rain' ? 0 : H / 2;
const p = new TextParticle(cx, cy, currentText, currentFont, currentSize, currentColor, currentCW, currentMode, i, n);
particles.push(p);
}
updateMetrics();
}
function clearStage() {
particles = [];
document.getElementById('hint').style.opacity = '0.6';
}
function quickText(t) {
currentText = t;
document.getElementById('text-input').value = t;
updateMetrics();
spawnAll();
}
let autoStep = 0;
const autoModes = ['burst','rain','wave','orbit','spiral','columns'];
const autoPhrases = ['Zero DOM reads.','Pure arithmetic.','Canvas measures,\nmath lays out.','500× faster!','Pretext.js ✓','Layout: instant.'];
function autoPlay() {
if (autoTimer) { clearInterval(autoTimer); autoTimer = null; return; }
autoTimer = setInterval(() => {
clearStage();
currentMode = autoModes[autoStep % autoModes.length];
currentText = autoPhrases[autoStep % autoPhrases.length];
document.getElementById('text-input').value = currentText;
document.querySelectorAll('.mode-pill').forEach(p => {
p.classList.toggle('active', p.dataset.mode === currentMode);
});
currentColor = ['#e84c1e','#2563eb','rainbow','#9333ea','#16a34a','#0891b2'][autoStep % 6];
spawnAll();
autoStep++;
}, 2800);
spawnAll();
}
// Click on stage
document.getElementById('stage').addEventListener('click', e => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
document.getElementById('hint').style.opacity = '0';
const p = new TextParticle(x, y, currentText, currentFont, currentSize, currentColor, currentCW, currentMode, 0, 1);
particles.push(p);
updateMetrics();
});
// Text input
document.getElementById('text-input').addEventListener('input', e => {
currentText = e.target.value || 'Hello, Pretext.js!';
updateMetrics();
});
// ============================================================
// CONTROLS
// ============================================================
function setMode(m, el) {
currentMode = m;
document.querySelectorAll('.mode-pill').forEach(p => p.classList.remove('active'));
el.classList.add('active');
}
function setFont(el) {
currentFont = el.dataset.font;
document.querySelectorAll('.font-swatch').forEach(f => f.classList.remove('active'));
el.classList.add('active');
updateMetrics();
}
function setColor(c, el) {
currentColor = c;
document.querySelectorAll('.color-dot').forEach(d => d.classList.remove('active'));
el.classList.add('active');
}
function updateSize(v) {
currentSize = parseInt(v);
document.getElementById('size-val').textContent = v + 'px';
updateMetrics();
}
function updateCW(v) {
currentCW = parseInt(v);
document.getElementById('cw-val').textContent = v + 'px';
updateMetrics();
}
function updateMetrics() {
const fontSize = currentSize;
const fontSpec = fontSize + 'px ' + currentFont.replace(/^\d+px\s*/, '');
const handle = pretextPrepare(currentText, fontSpec);
const layout = pretextLayout(handle, currentCW, fontSize * 1.3);
document.getElementById('m-width').textContent = Math.round(layout.textWidth) + 'px';
document.getElementById('m-lines').textContent = layout.lineCount;
document.getElementById('m-height').textContent = Math.round(layout.height) + 'px';
document.getElementById('m-reflows').textContent = '0';
const shortFont = currentFont.replace(/^\d+px\s*/, '').replace(/'/g,'');
document.getElementById('code-output').textContent = `prepare('${currentText.slice(0,22)}...', '${fontSize}px ${shortFont}')`;
}
// ============================================================
// INIT
// ============================================================
window.addEventListener('resize', () => { resize(); });
resize();
updateMetrics();
animId = requestAnimationFrame(render);
// Kick off with a burst demo
setTimeout(() => {
spawnAll();
}, 400);
</script>