From b043712447f84a85af14ba85d415c0357eace6c6 Mon Sep 17 00:00:00 2001 From: Aadharsh Pannirselvam <19518661+oddharsh@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:43:31 -0500 Subject: [PATCH] feat(pixel-peeper), feat(garage): the share card and a pretext panel draw real HTML into canvas Chrome 157 turned on HTML-in-Canvas (ctx.drawElementImage) by default on 2026-11-03. One canvas on the site was rebuilding what HTML already does, and one explainer was missing the other half of its argument. /pixel-peeper's share card was about 70 lines of fillRect, gradient stops and 16 fillText calls at hardcoded positions, in hex copied from luna.css. It's now HTML on the page's own tokens and classes, and it reuses the share grid and key the page shows above it. A hidden holder paints it, and drawElementImage copies it into the 1200x630 canvas that Copy image, Save PNG and Preview already used. drawCard stays as the fallback for Safari, Firefox and Chrome before 157, and for any draw that throws or reads back blank. Measured in Chrome 157 on 2026-11-10: a 1px clipping box at the viewport origin paints the whole card, even on a 390px phone and below the fold. visibility:hidden, display:none, a zero-size box and every off-screen position tried throw "No cached paint record". A canvas shrunk by CSS clips its drawable child, so the card draws in the holder and the finished canvas moves into the preview. /garage/pretext section 3 gets a second panel: the same paragraph wrapped around a real CSS float, drawn into a canvas on each paint event, driven by the same slider. It breaks line for line with pretext's layoutNextLine panel at 1280 and 390px. Browsers without the API hide the panel and say why. Brotli at q11: /pixel-peeper +967 B, /garage/pretext +589 B. No /a/ asset re-mints against origin/main 100efe68. --- src/pages/garage/pretext.html | 42 +++++++++++ src/pages/pixel-peeper/index.html | 115 ++++++++++++++++++++++++++---- 2 files changed, 144 insertions(+), 13 deletions(-) diff --git a/src/pages/garage/pretext.html b/src/pages/garage/pretext.html index d8ac1582d..ddf96f4ec 100644 --- a/src/pages/garage/pretext.html +++ b/src/pages/garage/pretext.html @@ -48,6 +48,10 @@ .float-stage { position: relative; border: 1px solid var(--slate-80); border-radius: 4px; background: var(--paper-cool); margin-top: 8px; overflow: hidden; padding: 0; } .float-box { position: absolute; top: 8px; left: 8px; z-index: 1; border-radius: 3px; color: #fff; font-size: 9px; line-height: 1.15; font-weight: bold; text-align: center; display: flex; align-items: center; justify-content: center; text-shadow: 0 1px 1px rgba(0,0,0,.4); background: repeating-linear-gradient(45deg, oklch(70% 0.13 255) 0 8px, oklch(60% 0.15 258) 8px 16px); } .float-stage canvas { display: block; width: 100%; } +/* the same float laid out by CSS, inside a drawable canvas (HTML-in-Canvas) */ +#float-hic { height: 156px; } +.hic-flow { box-sizing: border-box; height: 156px; overflow: hidden; padding: 8px; font: 15px/20px var(--font-ui); color: var(--ink); } +.hic-flow .float-box { position: static; float: left; margin: 0 10px 0 0; } /* i18n demo line rows */ .i18n-out .ln { white-space: nowrap; overflow: hidden; font-size: 15px; line-height: 22px; } .i18n-out.naive .ln.over { background: oklch(93% 0.07 30); color: var(--danger-deep); border-radius: 2px; } @@ -139,6 +143,44 @@

3 · Dynamic wrapping: flow around a float

floated
object
+

+ Or skip the JS layout. HTML-in-Canvas + (Chrome 157 and later) lets the browser lay out a real CSS float, + then copies the painted result into a canvas with ctx.drawElementImage(). + The same paragraph and the same slider drive both: +

+
+
floated
object
Pretext lays out one line at a time via layoutNextLine, so the available width can change per line. The first lines wrap beside the floated box at a narrower width; once the text clears it, they reclaim the full width below: shaped wrapping with no DOM, recomputed live as you resize the float.
+
+ +

+ The trade runs the other way from pretext. Every slider tick costs the + browser a layout pass, the cost section 1 is about. In return the canvas + gets the browser's whole CSS: every script, font-feature-settings, + hyphenation, and an accessibility tree for the text, none of which + pretext's subset covers. It redraws on the canvas's paint + event, which fires whenever the drawn element changes. +

+

4 · International text: why a toy isn't enough

diff --git a/src/pages/pixel-peeper/index.html b/src/pages/pixel-peeper/index.html index 12e1fa2de..70014454f 100644 --- a/src/pages/pixel-peeper/index.html +++ b/src/pages/pixel-peeper/index.html @@ -157,6 +157,41 @@ .lkey{display:flex;flex-wrap:wrap;gap:4px 12px;font-family:var(--font-mono);font-size:var(--text-2xs);color:var(--ink-dim)} .zoompatch{width:192px;max-width:calc(50% - 5px);height:auto;aspect-ratio:1;image-rendering:pixelated;background:#000;border:2px solid var(--shadow);box-shadow:var(--bevel-sunken);border-radius:2px} .sharecard{display:block;width:100%;height:auto;margin:8px 0 0;border:1px solid var(--shadow);box-shadow:var(--bevel-sunken)} +/* The share card as HTML, painted into its 1200x630 canvas by HTML-in-Canvas + (cardCanvas below). The holder has to keep the canvas laid out and painted + but invisible. Measured in Chrome 157 on 2026-11-10: a 1px clipping box at + the viewport origin draws the whole card, even on a 390px phone and below + the fold. visibility:hidden, display:none, a zero-size box, and parking it + off-screen (left:-10000px, left:200vw, a translate) all throw "No cached + paint record". A canvas shrunk by CSS clips its drawable child, so the card + never draws in the preview itself. */ +.cardhold{position:fixed;left:0;top:0;width:1px;height:1px;overflow:hidden;pointer-events:none} +.pc{box-sizing:border-box;width:1200px;height:630px;display:flex;flex-direction:column;background:var(--face);color:var(--ink-soft);font:18px var(--font-ui)} +.pc small{font:15px var(--font-mono);color:var(--ink-dim)} +.pc-title{height:56px;flex:none;display:flex;align-items:center;justify-content:space-between;padding:0 24px;background:var(--grad-title);color:#fff;font:bold 24px var(--font-caption)} +.pc-title small{color:rgba(255,255,255,.85)} +.pc-body{box-sizing:border-box;height:346px;display:flex;gap:40px;padding:44px 48px 0 56px} +.pc-score{flex:none;width:504px} +.pc-run{min-width:0} +.pc-pct{font:bold 130px/1 var(--font-mono);color:var(--navy);letter-spacing:0;margin:14px 0 0 -4px} +.pc-pct span{font-size:50px;margin-left:8px} +.pc-grade{font:bold 34px var(--font-caption);color:var(--go);margin:8px 0 6px} +.pc-run small{display:block;margin-bottom:12px} +.pc .sharegrid{gap:12px;align-items:flex-start;margin:0 0 18px} +.pc .sharegrid .r{gap:12px} +.pc .sq{width:70px;height:70px;border-radius:0} +.pc .sharekey{justify-content:flex-start;gap:8px 22px;font-size:14px;color:var(--ink-soft)} +.pc .sharekey i{display:inline-block;width:13px;height:13px;margin-right:6px;vertical-align:-1px;box-shadow:var(--bevel-raised)} +.pc-axis{padding:0 56px} +.pc-axis .axislabels{font-size:22px} +.pc-axis .axislabels small{font-size:13px;margin-top:2px} +.pc-bar{position:relative;height:20px;margin:8px 0;border:1px solid var(--shadow)} +/* inset 11px so a 100%-one-way lean still draws a whole arrowhead inside the bar */ +.pc-needle{position:absolute;top:-8px;width:3px;height:36px;margin-left:-1.5px;background:var(--navy)} +.pc-needle::before{content:"";position:absolute;left:-7.5px;top:0;border:9px solid transparent;border-top:15px solid var(--navy);border-bottom:0} +.pc-lean{margin:26px 56px 0;text-align:center;font-size:20px;color:var(--ink)} +.pc-foot{height:52px;flex:none;margin-top:auto;display:flex;align-items:center;justify-content:space-between;padding:0 24px;border-top:1px solid var(--shadow)} +.pc-foot b{font:15px var(--font-mono);color:var(--link)} /* metric-alignment axis (the hero) */ .axiswrap{margin:4px 2px 2px} .axislabels{display:flex;justify-content:space-between;font-family:var(--font-caption);font-weight:700;font-size:var(--text-ui)} @@ -889,13 +924,63 @@

${showdown?`${FMT_LIST()}, through your eyes`:"your visual system, profiled" return seen.map(k=>`${MARK[k].t}`).join(""); } -// The PNG. Twitter renders an image far louder than it renders text art, and -// canvas keeps the site's no-web-fonts rule: every face here is one the OS -// already has, exactly like the page itself. +// the needle line: which metric this eye thinks like, or in a showdown, which format it picked +const needleLabels=showdown=>showdown?{l:"JPEG XL",r:state.av2?"AVIF or AV2":"AVIF",ls:"picked more often",rs:"picked more often",cl:"#0a246a",cr:"#0a246a",gl:"#8a866f",gr:"#8a866f"} + :{l:"ssimulacra2",r:"butteraugli",ls:"structure / detail",rs:"color / masking",cl:"#0b57c9",cr:"#9c640d",gl:"#0b57c9",gr:"#c8811c"}; +const cardFoot=showdown=>(showdown?"real encodes · cjxl · avifenc":"real encodes · zenc · mozjpeg · sips · cjxl · avifenc")+(state.av2?" · AVM":"")+" · equal byte budgets"; + +// The card as the page's own HTML and tokens, for HTML-in-Canvas. It reuses the +// share grid and key the page shows above it, so the two can't drift. +function cardHTML(m,overall,grade,clearLine,leanLine,lean,decisive,showdown){ + const L=needleLabels(showdown); + return `
+
${showdown?`Pixel Peeper · ${VS()}`:"Pixel Peeper · compression eye exam"}aadhar.sh/pixel-peeper
+
+
your visual system, profiled +
${overall}%
+
${grade}
${clearLine}
+
how the calls went${shareGridHTML(m)}
${shareKeyHTML(m)}
+
+
+
${L.l}${L.r}
+
${decisive?``:""}
+
${L.ls}${L.rs}
+
+
${leanLine}
+
${cardFoot(showdown)}whose eye do you have?
+
`; +} + +// Paints the HTML card into a canvas on the browser's next paint. Rejects when +// the browser can't, so the caller falls back to drawCard. +function cardCanvas(html){ + return new Promise((resolve,reject)=>{ + const hold=document.createElement("div");hold.className="cardhold";hold.setAttribute("aria-hidden","true"); + const cv=document.createElement("canvas");cv.width=1200;cv.height=630;cv.setAttribute("content","drawable"); + cv.innerHTML=html;hold.appendChild(cv);document.body.appendChild(hold); + const fail=e=>{hold.remove();reject(e);}; + const late=setTimeout(()=>fail(new Error("no paint event")),2000); + cv.addEventListener("paint",()=>{ + clearTimeout(late); + try{ + const x=cv.getContext("2d");x.reset();x.drawElementImage(cv.firstElementChild,0,0); + // On this page an unpainted source throws, but on a bare test page in + // Chrome 157 (2026-11-10) an off-screen one drew transparent without + // throwing, so read the title bar back before trusting the card. + if(x.getImageData(600,28,1,1).data[3]!==255) throw new Error("card drew blank"); + cv.replaceChildren();cv.removeAttribute("content");hold.remove();resolve(cv); + }catch(e){fail(e);} + },{once:true}); + cv.requestPaint(); + }); +} + +// The canvas-primitives card, for browsers without HTML-in-Canvas (Safari and +// Firefox, Chrome before 157). Twitter renders an image far louder than it +// renders text art, and canvas keeps the site's no-web-fonts rule: every face +// here is one the OS already has, exactly like the page itself. function drawCard(m,overall,grade,clearLine,leanLine,lean,decisive,showdown){ - // the needle line: which metric this eye thinks like, or in a showdown, which format it picked - const L=showdown?{l:"JPEG XL",r:state.av2?"AVIF or AV2":"AVIF",ls:"picked more often",rs:"picked more often",cl:"#0a246a",cr:"#0a246a",gl:"#8a866f",gr:"#8a866f"} - :{l:"ssimulacra2",r:"butteraugli",ls:"structure / detail",rs:"color / masking",cl:"#0b57c9",cr:"#9c640d",gl:"#0b57c9",gr:"#c8811c"}; + const L=needleLabels(showdown); const W=1200,H=630,cv=document.createElement("canvas");cv.width=W;cv.height=H; const x=cv.getContext("2d"); const UI='Tahoma,Verdana,Geneva,sans-serif',CAP='"Trebuchet MS",Verdana,sans-serif',MONO='"Courier New",Courier,monospace'; @@ -962,8 +1047,7 @@

${showdown?`${FMT_LIST()}, through your eyes`:"your visual system, profiled" // footer x.fillStyle="#ACA899";x.fillRect(0,H-52,W,1); x.fillStyle="#636363";x.font=`15px ${MONO}`; - const avm=state.av2?" · AVM":""; - x.fillText(showdown?`real encodes · cjxl · avifenc${avm} · equal byte budgets`:`real encodes · zenc · mozjpeg · sips · cjxl · avifenc${avm} · equal byte budgets`,24,H-22); + x.fillText(cardFoot(showdown),24,H-22); x.textAlign="right";x.fillStyle="#0b3fae"; x.fillText("whose eye do you have?",W-24,H-22);x.textAlign="left"; return cv; @@ -972,8 +1056,12 @@

${showdown?`${FMT_LIST()}, through your eyes`:"your visual system, profiled" function wireShare(m,overall,grade,clearLine,leanLine,lean,decisive,showdown){ const txt=shareText(m,`${showdown?`${VS()} · `:""}${overall}% · ${grade}`,leanLine,showdown); const flash=(el,msg)=>{const was=el.textContent;el.textContent=msg;setTimeout(()=>el.textContent=was,1500);}; - let card=null; const png=()=>(card=card||drawCard(m,overall,grade,clearLine,leanLine,lean,decisive,showdown)); - const blob=()=>new Promise(res=>png().toBlob(res,"image/png")); + const args=[m,overall,grade,clearLine,leanLine,lean,decisive,showdown]; + let card=null; + const png=()=>(card=card||("drawElementImage" in CanvasRenderingContext2D.prototype + ?cardCanvas(cardHTML(...args)).catch(()=>drawCard(...args)) + :Promise.resolve(drawCard(...args)))); + const blob=()=>png().then(c=>new Promise(res=>c.toBlob(res,"image/png"))); document.getElementById("copytext").onclick=ev=>{ navigator.clipboard&&navigator.clipboard.writeText(txt);flash(ev.target,"Copied ✓");}; @@ -997,9 +1085,10 @@

${showdown?`${FMT_LIST()}, through your eyes`:"your visual system, profiled" document.getElementById("showcard").onclick=ev=>{ const holder=document.getElementById("cardwrap"); if(holder.firstChild){holder.innerHTML="";ev.target.textContent="Preview image";return;} - const c=png();c.className="sharecard";c.setAttribute("role","img"); - c.setAttribute("aria-label",`Pixel Peeper result card: ${overall}%, ${grade}. ${leanLine}`); - holder.appendChild(c);ev.target.textContent="Hide image"; + ev.target.textContent="Hide image"; + png().then(c=>{c.className="sharecard";c.setAttribute("role","img"); + c.setAttribute("aria-label",`Pixel Peeper result card: ${overall}%, ${grade}. ${leanLine}`); + holder.appendChild(c);}); }; }