#!/usr/bin/env node /* bench-compiled.mjs: is a compiled DSSL page as fast as the same page written as plain CSS? * * "Startup within 10% of equivalent CSS; churn at parity." The page is 4000 cards, each with a title, a paragraph and a tag. * Three versions of it are measured in real Chrome, interleaved (so drift hits all three the same), many times each: * * hand CSS written by hand, literal values, no custom properties: the best plain CSS can do * compiled the same design as DSSL, through `dssl build` (zero-specificity :where() selectors, var() tokens) * inlined the same, built with --inline-tokens (each token's value written in place of var()) * engine the same DSSL, applied by the runtime engine (inline styles). Reported, never asserted: it is the slow lane. * * startup = navigation start to the first paint of the finished page. For the engine that is the moment it says it is ready, * because its first paint is the unstyled page * load = the load event * churn = flip a class on every card, then force style recalculation and layout; median of 60 flips * restyle = change something on that every card inherits from, then force style and layout * * node scripts/bench-compiled.mjs [--runs 21] [--cards 4000] exit 1 if the inlined build is more than 10% behind hand */ import { createServer } from 'node:http'; import { readFileSync } from 'node:fs'; import path from 'node:path'; import { chromium } from 'playwright-core'; import { toCSS } from '../public/dssl/index.js'; import { findChrome, ROOT } from './lib/browser-harness.mjs'; const arg = (n, d) => { const i = process.argv.indexOf(n); return i > -1 ? Number(process.argv[i + 1]) : d; }; const RUNS = arg('--runs', 21), CARDS = arg('--cards', 4000), FLIPS = 60; // the engine gets 8 flips: it is far slower per flip and would take an hour const DSSL = `tokens { color { ink: #1a1426; card: #ffffff; line: #dcd7e6; accent: #d1006f; muted: #5d5570 } } .grid { grid: auto-fill 180; gap: space.3; padding: space.3 } .card { background: color.card; border: 1 solid color.line; radius: md; padding: space.3; shadow: sm; column: gap 6; transition: 150 } .card:hover { shadow: md; translate: 0 -2 } .card.on { background: color.accent; color: white } .title { font-weight: 700; font-size: 15; color: color.ink } .text { color: color.muted; font-size: 13; line-height: 1.5 } .tag { padding: 2 8; radius: full; background: color.accent/12; color: color.accent; font-size: 11 } @media (width < 700) { .grid { grid-template-columns: 1fr } }`; // the same design, by hand: literal values, the way a person writes CSS who has no tokens const HAND = `.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; padding: 12px } .card { background: #ffffff; border: 1px solid #dcd7e6; border-radius: 14px; padding: 12px; box-shadow: 0 1px 2px color-mix(in oklab, #1a1426 8%, transparent), 0 1px 3px color-mix(in oklab, #1a1426 6%, transparent); display: flex; flex-direction: column; gap: 6px; transition: all 150ms ease } .card:hover { box-shadow: 0 4px 8px color-mix(in oklab, #1a1426 10%, transparent); translate: 0 -2px } .card.on { background: #d1006f; color: white } .title { font-weight: 700; font-size: 15px; color: #1a1426 } .text { color: #5d5570; font-size: 13px; line-height: 1.5 } .tag { padding: 2px 8px; border-radius: 9999px; background: color-mix(in oklab, #d1006f 12%, transparent); color: #d1006f; font-size: 11px } @media (width < 700px) { .grid { grid-template-columns: 1fr } }`; const compiled = toCSS(DSSL, { banner: false }).css; const inlined = toCSS(DSSL, { banner: false, inlineTokens: true }).css; const cards = Array.from({ length: CARDS }, (_, i) => `
Card ${i}

Some text that wraps onto a second line when the column is narrow enough, to give layout real work.

tag ${i % 9}
`).join(''); const doc = (head) => `bench${head}
${cards}
`; const pages = { '/hand': doc(``), '/compiled': doc(``), '/inlined': doc(``), '/engine': doc(``), }; const server = createServer((req, res) => { const u = req.url.split('?')[0]; if (pages[u]) { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(pages[u]); } if (u.startsWith('/dssl/')) { try { const b = readFileSync(path.join(ROOT, 'public', u)); res.writeHead(200, { 'content-type': 'text/javascript' }); return res.end(b); } catch { /* 404 */ } } res.writeHead(404); res.end(); }).listen(0); const port = server.address().port; const median = (a) => { const s = [...a].sort((x, y) => x - y); return s[Math.floor(s.length / 2)]; }; const browser = await chromium.launch({ executablePath: findChrome(), headless: true, args: ['--no-sandbox', '--disable-gpu', '--hide-scrollbars'] }); async function once(kind) { const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 }, reducedMotion: 'reduce' }); const p = await ctx.newPage(); await p.addInitScript(() => { window.addEventListener('dssl-ready', () => { window.__ready = performance.now(); }); }); await p.goto(`http://127.0.0.1:${port}/${kind}`, { waitUntil: 'load' }); if (kind === 'engine') { await p.waitForFunction('window.DSSL && document.documentElement.classList.contains("dssl-ready")'); await p.waitForTimeout(150); } const r = await p.evaluate(async ({ flips, engine }) => { const out = {}; const frame0 = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); await frame0(); await new Promise((r) => setTimeout(r, 60)); const fcp = performance.getEntriesByName('first-contentful-paint')[0]; const nav = performance.getEntriesByType('navigation')[0]; out.fcp = engine ? (window.__ready || NaN) : (fcp ? fcp.startTime : NaN); out.load = nav.loadEventEnd; const frame = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); const els = [...document.querySelectorAll('.card')]; const flip = [], inh = []; for (let i = 0; i < flips; i++) { const t0 = performance.now(); for (const el of els) el.classList.toggle('on'); if (engine) await frame(); else void document.body.offsetHeight; // the engine restyles on a later frame; CSS restyles at the read void document.body.offsetHeight; flip.push(performance.now() - t0); } for (let i = 0; i < flips; i++) { const t0 = performance.now(); document.body.style.fontSize = i % 2 ? '14px' : '15px'; void document.body.offsetHeight; inh.push(performance.now() - t0); } const med = (a) => { const s = [...a].sort((x, y) => x - y); return s[Math.floor(s.length / 2)]; }; out.flip = med(flip.slice(Math.min(5, flips - 3))); out.restyle = med(inh.slice(Math.min(5, flips - 3))); return out; }, { flips: kind === 'engine' ? 8 : FLIPS, engine: kind === 'engine' }); await ctx.close(); return r; } const results = { hand: [], compiled: [], inlined: [], engine: [] }; await once('hand'); // warm the browser, discard for (let i = 0; i < RUNS; i++) for (const k of i % 2 ? ['engine', 'inlined', 'compiled', 'hand'] : ['hand', 'compiled', 'inlined', 'engine']) results[k].push(await once(k)); await browser.close(); server.close(); const col = (k, f) => { const v = results[k].map((r) => r[f]).filter((x) => Number.isFinite(x)); return v.length ? median(v) : NaN; }; const rows = [['startup (first paint, ms)', 'fcp'], ['load event (ms)', 'load'], ['churn: flip a class on every card (ms)', 'flip'], ['restyle: change an inherited value (ms)', 'restyle']]; console.log(`\n${CARDS} cards, ${RUNS} runs each, medians, interleaved. Chrome ${browser.version()}\n`); console.log(' hand compiled vs hand inlined vs hand engine vs hand'); let worst = 0, worstInl = 0; const pct = (a, b) => ((a / b) - 1) * 100; const fmt = (v) => (v >= 0 ? '+' : '') + v.toFixed(1) + '%'; for (const [label, f] of rows) { const h = col('hand', f), c = col('compiled', f), n = col('inlined', f), e = col('engine', f); if (f === 'fcp' || f === 'flip' || f === 'restyle') { worst = Math.max(worst, pct(c, h)); worstInl = Math.max(worstInl, pct(n, h)); } console.log(`${label.padEnd(42)} ${h.toFixed(1).padStart(7)} ${c.toFixed(1).padStart(9)} ${fmt(pct(c, h)).padStart(8)} ${n.toFixed(1).padStart(9)} ${fmt(pct(n, h)).padStart(8)} ${e.toFixed(1).padStart(9)} ${(pct(e, h) >= 0 ? '+' : '') + pct(e, h).toFixed(0) + '%'}`); } const bytes = (s) => Buffer.byteLength(s); console.log(`\nstylesheet size: hand ${bytes(HAND)} B, compiled ${bytes(compiled)} B, inlined ${bytes(inlined)} B (the build also carries the token table and every default token)`); console.log(`\nworst gap to hand-written CSS on startup, churn and restyle: compiled ${fmt(worst)} inlined ${fmt(worstInl)}`); const verdict = worstInl <= 10; console.log(`inlined build: ${verdict ? 'within 10%' : 'MORE than 10% behind'} (the claim is about the compiled page at its fastest; the default build keeps tokens as variables so they can change)`); process.exit(verdict ? 0 : 1);