A Pen by semanticentity on CodePen.
Created
December 20, 2025 09:28
-
-
Save semanticentity/147734c5e4a35940928705e5f3c9e97a to your computer and use it in GitHub Desktop.
Regions, Not Rectangles
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <div id="app"> | |
| <header class="panel"> | |
| <div class="titleRow"> | |
| <h1>Regions, Not Rectangles</h1> | |
| <div class="badge">Based on interview with <a href="https://www.youtube.com/watch?v=kGIIwyJ7G94" target="_blank">Bill Atkinson, discussing Lisa source code</a></div> | |
| </div> | |
| <p class="sub"> | |
| A web window manager demo: visible regions are stored as <strong>scanline deltas</strong> and rendered as | |
| <strong>top‑down slabs</strong>. (This is the core idea Bill Atkinson is describing.) | |
| </p> | |
| <p class="quote"> | |
| “It’s not a rectangle of drawing space — it’s an arbitrary shape.” | |
| </p> | |
| </header> | |
| <main class="layout"> | |
| <section class="stage panel"> | |
| <div class="stageTop"> | |
| <div class="kioskLabel">720 × 360 • 1‑bit vibe • region clipping</div> | |
| <div id="hud" class="hud" aria-live="polite"></div> | |
| </div> | |
| <canvas id="ui" width="720" height="360" tabindex="0" aria-describedby="desc"></canvas> | |
| <div class="hint"> | |
| Mouse: click to front, drag to move. Keyboard: <kbd>[</kbd>/<kbd>]</kbd> cycle, arrows move, <kbd>Enter</kbd> bring front. | |
| </div> | |
| </section> | |
| <aside class="side"> | |
| <section class="panel controls"> | |
| <h2>Controls</h2> | |
| <label class="row"> | |
| <input type="checkbox" id="showRegions" checked /> | |
| Show visible regions (x‑ray) | |
| </label> | |
| <label class="row"> | |
| <input type="checkbox" id="showBands" checked /> | |
| Show band breaks (where region shape changes) | |
| </label> | |
| <label class="row"> | |
| <input type="checkbox" id="naiveMode" /> | |
| Naive redraw (draw full rects; “Xerox‑ish”) | |
| </label> | |
| <label class="row"> | |
| Scan step | |
| <select id="scanStep"> | |
| <option value="1" selected>1px (faithful)</option> | |
| <option value="2">2px (faster)</option> | |
| <option value="4">4px (coarser)</option> | |
| </select> | |
| </label> | |
| </section> | |
| <section class="panel windows"> | |
| <h2>Windows</h2> | |
| <p class="mini"> | |
| These buttons are the <strong>semantic / a11y mirror</strong> (screen readers & SEO). | |
| The canvas is just the visual + region engine. | |
| </p> | |
| <div id="winList" class="winList" role="list"></div> | |
| </section> | |
| <section id="desc" class="panel desc"> | |
| <h2>What you’re seeing</h2> | |
| <ul> | |
| <li><strong>Structure region</strong>: the full window rect.</li> | |
| <li><strong>Visible region</strong>: structure minus any windows above it.</li> | |
| <li>Visible region is stored as <strong>scanline spans</strong>, then <strong>compressed into bands</strong> (only when spans change).</li> | |
| <li>Rendering copies only those spans as <strong>slabs</strong> — fast, like Bill’s “write in slabs top‑down” description.</li> | |
| </ul> | |
| </section> | |
| </aside> | |
| </main> | |
| </div> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| const canvas = document.getElementById("ui"); | |
| const ctx = canvas.getContext("2d"); | |
| const hud = document.getElementById("hud"); | |
| const winList = document.getElementById("winList"); | |
| const showRegions = document.getElementById("showRegions"); | |
| const showBands = document.getElementById("showBands"); | |
| const naiveMode = document.getElementById("naiveMode"); | |
| const scanStepSel = document.getElementById("scanStep"); | |
| const W = canvas.width; | |
| const H = canvas.height; | |
| let activeId = null; | |
| let hoverId = null; | |
| let dragging = null; | |
| let dragOffset = { x: 0, y: 0 }; | |
| let layoutDirty = true; | |
| let rafPending = false; | |
| /* ------------------------- | |
| 1‑bit-ish patterns | |
| -------------------------- */ | |
| function makePattern(kind){ | |
| const p = document.createElement("canvas"); | |
| p.width = 8; p.height = 8; | |
| const pctx = p.getContext("2d"); | |
| pctx.fillStyle = "#fff"; | |
| pctx.fillRect(0,0,8,8); | |
| pctx.fillStyle = "#000"; | |
| if (kind === "light") { | |
| // sparse dots | |
| for (let y=0;y<8;y++){ | |
| for (let x=0;x<8;x++){ | |
| if ((x+y) % 7 === 0) pctx.fillRect(x,y,1,1); | |
| } | |
| } | |
| } else if (kind === "mid") { | |
| // checker | |
| for (let y=0;y<8;y++){ | |
| for (let x=0;x<8;x++){ | |
| if ((x+y) % 2 === 0) pctx.fillRect(x,y,1,1); | |
| } | |
| } | |
| } else if (kind === "diag") { | |
| // diagonal stripes | |
| for (let y=0;y<8;y++){ | |
| for (let x=0;x<8;x++){ | |
| if ((x - y + 16) % 4 === 0) pctx.fillRect(x,y,1,1); | |
| } | |
| } | |
| } | |
| return ctx.createPattern(p, "repeat"); | |
| } | |
| const PAT_LIGHT = makePattern("light"); | |
| const PAT_MID = makePattern("mid"); | |
| const PAT_DIAG = makePattern("diag"); | |
| /* ------------------------- | |
| Windows (structure regions) | |
| Order is back->front. | |
| -------------------------- */ | |
| const windows = [ | |
| mkWindow("write", "Write", 48, 42, 320, 220, PAT_LIGHT, drawWrite), | |
| mkWindow("chart", "Chart", 210, 80, 320, 220, PAT_MID, drawChart), | |
| mkWindow("tools", "Tools", 430, 120, 240, 190, PAT_DIAG, drawTools), | |
| ]; | |
| activeId = windows[windows.length-1].id; | |
| function mkWindow(id, title, x, y, w, h, fillPat, drawFn){ | |
| const win = { | |
| id, title, x, y, w, h, | |
| fillPat, | |
| drawFn, | |
| buf: null, | |
| visibleBands: [], // computed | |
| metrics: { bands:0, spans:0, pixels:0, blits:0 } | |
| }; | |
| win.buf = renderWindowBuffer(win); | |
| return win; | |
| } | |
| /* ------------------------- | |
| Offscreen buffer == “picture” | |
| (recorded result; we blit parts) | |
| -------------------------- */ | |
| function renderWindowBuffer(win){ | |
| const b = document.createElement("canvas"); | |
| b.width = win.w; | |
| b.height = win.h; | |
| const bctx = b.getContext("2d"); | |
| // frame | |
| bctx.fillStyle = "#fff"; | |
| bctx.fillRect(0,0,win.w,win.h); | |
| // title bar | |
| const tb = 18; | |
| bctx.fillStyle = win.fillPat; | |
| bctx.fillRect(0,0,win.w,tb); | |
| bctx.strokeStyle = "#000"; | |
| bctx.lineWidth = 2; | |
| bctx.strokeRect(1,1,win.w-2,win.h-2); | |
| // title text | |
| bctx.fillStyle = "#000"; | |
| bctx.font = "12px monospace"; | |
| bctx.fillText(win.title, 10, 13); | |
| // close box (purely visual) | |
| bctx.strokeRect(4,4,10,10); | |
| bctx.beginPath(); | |
| bctx.moveTo(5,5); bctx.lineTo(13,13); | |
| bctx.moveTo(13,5); bctx.lineTo(5,13); | |
| bctx.stroke(); | |
| // content | |
| bctx.save(); | |
| bctx.translate(0, tb); | |
| const innerH = win.h - tb; | |
| bctx.fillStyle = "#fff"; | |
| bctx.fillRect(0,0,win.w,innerH); | |
| // subtle dither background | |
| bctx.globalAlpha = 0.25; | |
| bctx.fillStyle = win.fillPat; | |
| bctx.fillRect(0,0,win.w,innerH); | |
| bctx.globalAlpha = 1; | |
| // content drawing | |
| win.drawFn(bctx, win.w, innerH); | |
| bctx.restore(); | |
| return b; | |
| } | |
| /* ------------------------- | |
| Content functions | |
| -------------------------- */ | |
| function drawWrite(bctx, w, h){ | |
| bctx.fillStyle = "#000"; | |
| bctx.font = "12px monospace"; | |
| const lines = [ | |
| "This window is BEHIND sometimes.", | |
| "When covered, its visible region", | |
| "becomes a puzzle piece.", | |
| "", | |
| "We don’t redraw rectangles.", | |
| "We blit only visible slabs.", | |
| ]; | |
| let y = 18; | |
| for (const s of lines){ | |
| bctx.fillText(s, 10, y); | |
| y += 16; | |
| } | |
| // fake caret | |
| bctx.fillRect(10, y+4, 2, 12); | |
| } | |
| function drawChart(bctx, w, h){ | |
| bctx.fillStyle = "#000"; | |
| bctx.font = "12px monospace"; | |
| bctx.fillText("Bars are drawn once into the buffer.", 10, 18); | |
| const baseY = 160; | |
| const bars = [40, 110, 70, 140, 95]; | |
| for (let i=0;i<bars.length;i++){ | |
| const x = 20 + i*50; | |
| const bh = bars[i]; | |
| bctx.fillStyle = (i%2===0) ? "#000" : "#fff"; | |
| bctx.strokeStyle = "#000"; | |
| bctx.fillRect(x, baseY-bh, 28, bh); | |
| bctx.strokeRect(x, baseY-bh, 28, bh); | |
| } | |
| // “pie” ring | |
| bctx.beginPath(); | |
| bctx.arc(w-70, 120, 38, 0, Math.PI*1.35); | |
| bctx.stroke(); | |
| } | |
| function drawTools(bctx, w, h){ | |
| bctx.fillStyle = "#000"; | |
| bctx.font = "12px monospace"; | |
| bctx.fillText("Drag me on top.", 10, 18); | |
| // knobs | |
| for (let i=0;i<3;i++){ | |
| const cx = 40 + i*65; | |
| const cy = 75; | |
| bctx.beginPath(); | |
| bctx.arc(cx, cy, 18, 0, Math.PI*2); | |
| bctx.stroke(); | |
| bctx.beginPath(); | |
| bctx.moveTo(cx, cy); | |
| bctx.lineTo(cx+10, cy-8); | |
| bctx.stroke(); | |
| } | |
| // toggles | |
| bctx.strokeRect(10, 110, w-20, 60); | |
| bctx.fillText("Visible region math", 16, 132); | |
| bctx.fillText("happens in the WM", 16, 150); | |
| } | |
| /* ------------------------- | |
| Region math: subtract occluders | |
| We store as scanline spans, then | |
| compress into “bands” where spans | |
| don’t change across adjacent lines. | |
| -------------------------- */ | |
| function subtractIntervalList(spans, cut){ | |
| const [c0, c1] = cut; | |
| const out = []; | |
| for (const [s0, s1] of spans){ | |
| if (c1 <= s0 || c0 >= s1) { | |
| out.push([s0, s1]); | |
| } else { | |
| if (c0 > s0) out.push([s0, Math.min(c0, s1)]); | |
| if (c1 < s1) out.push([Math.max(c1, s0), s1]); | |
| } | |
| } | |
| return out; | |
| } | |
| function spansKey(spans){ | |
| // stable compare for band compression | |
| return spans.map(([a,b]) => `${a},${b}`).join("|"); | |
| } | |
| function computeVisibleBandsForWindow(i, step){ | |
| const win = windows[i]; | |
| const x0 = Math.max(0, Math.floor(win.x)); | |
| const y0 = Math.max(0, Math.floor(win.y)); | |
| const x1 = Math.min(W, Math.ceil(win.x + win.w)); | |
| const y1 = Math.min(H, Math.ceil(win.y + win.h)); | |
| const bands = []; | |
| let band = null; | |
| let prevKey = null; | |
| // metrics | |
| let bandCount = 0, spanCount = 0, pixels = 0, blits = 0; | |
| for (let y=y0; y<y1; y+=step){ | |
| let spans = [[x0, x1]]; | |
| // subtract every window ABOVE (i+1..end) | |
| for (let j=i+1; j<windows.length; j++){ | |
| const o = windows[j]; | |
| const oy0 = Math.floor(o.y); | |
| const oy1 = Math.ceil(o.y + o.h); | |
| if (y >= oy0 && y < oy1){ | |
| const cut = [Math.floor(o.x), Math.ceil(o.x + o.w)]; | |
| spans = subtractIntervalList(spans, cut); | |
| if (spans.length === 0) break; | |
| } | |
| } | |
| // clamp + clean | |
| spans = spans | |
| .map(([a,b]) => [Math.max(0,a), Math.min(W,b)]) | |
| .filter(([a,b]) => b > a); | |
| const key = spansKey(spans); | |
| if (band && key === prevKey) { | |
| band.h += step; | |
| } else { | |
| if (band) bands.push(band); | |
| band = { y, h: step, spans }; | |
| prevKey = key; | |
| bandCount++; | |
| } | |
| for (const [a,b] of spans){ | |
| spanCount++; | |
| pixels += (b-a) * step; | |
| blits++; | |
| } | |
| } | |
| if (band) bands.push(band); | |
| win.metrics = { bands: bandCount, spans: spanCount, pixels, blits }; | |
| return bands; | |
| } | |
| function recomputeLayout(){ | |
| const step = parseInt(scanStepSel.value, 10); | |
| for (let i=0;i<windows.length;i++){ | |
| windows[i].visibleBands = computeVisibleBandsForWindow(i, step); | |
| } | |
| layoutDirty = false; | |
| } | |
| /* ------------------------- | |
| Render: “copy bits” by slabs | |
| (blit only visible spans) | |
| -------------------------- */ | |
| function render(){ | |
| if (layoutDirty) recomputeLayout(); | |
| // background | |
| ctx.fillStyle = "#efefef"; | |
| ctx.fillRect(0,0,W,H); | |
| // subtle stipple background | |
| ctx.globalAlpha = 0.15; | |
| ctx.fillStyle = PAT_LIGHT; | |
| ctx.fillRect(0,0,W,H); | |
| ctx.globalAlpha = 1; | |
| let regionPixels = 0, regionBlits = 0, regionBands = 0; | |
| let naivePixels = 0; | |
| if (naiveMode.checked){ | |
| // draw full rect for each window, back->front | |
| for (const win of windows){ | |
| ctx.drawImage(win.buf, Math.floor(win.x), Math.floor(win.y)); | |
| naivePixels += win.w * win.h; | |
| } | |
| } else { | |
| // slab blit only visible spans | |
| for (const win of windows){ | |
| const wx = Math.floor(win.x); | |
| const wy = Math.floor(win.y); | |
| for (const band of win.visibleBands){ | |
| const sy = band.y - wy; | |
| for (const [a,b] of band.spans){ | |
| const sx = a - wx; | |
| const sw = b - a; | |
| // source is win.buf (window local), dest is screen | |
| ctx.drawImage( | |
| win.buf, | |
| sx, sy, sw, band.h, | |
| a, band.y, sw, band.h | |
| ); | |
| regionPixels += sw * band.h; | |
| regionBlits += 1; | |
| } | |
| regionBands += 1; | |
| } | |
| } | |
| } | |
| // overlays: visible regions (x-ray) | |
| if (showRegions.checked){ | |
| ctx.save(); | |
| ctx.globalAlpha = 0.22; | |
| // draw x‑ray fills by window, back->front | |
| for (let i=0;i<windows.length;i++){ | |
| const win = windows[i]; | |
| const fill = (i%2===0) ? PAT_MID : PAT_DIAG; | |
| ctx.fillStyle = fill; | |
| for (const band of win.visibleBands){ | |
| for (const [a,b] of band.spans){ | |
| ctx.fillRect(a, band.y, b-a, band.h); | |
| } | |
| } | |
| } | |
| ctx.restore(); | |
| // outlines (crisp) | |
| ctx.save(); | |
| ctx.strokeStyle = "rgba(0,0,0,0.6)"; | |
| ctx.setLineDash([4,4]); | |
| for (const win of windows){ | |
| for (const band of win.visibleBands){ | |
| for (const [a,b] of band.spans){ | |
| ctx.strokeRect(a+0.5, band.y+0.5, (b-a)-1, band.h-1); | |
| } | |
| } | |
| } | |
| ctx.restore(); | |
| } | |
| // band breaks | |
| if (showBands.checked && !naiveMode.checked){ | |
| ctx.save(); | |
| ctx.strokeStyle = "rgba(0,0,0,0.35)"; | |
| for (const win of windows){ | |
| for (const band of win.visibleBands){ | |
| ctx.beginPath(); | |
| ctx.moveTo(0, band.y+0.5); | |
| ctx.lineTo(W, band.y+0.5); | |
| ctx.stroke(); | |
| } | |
| } | |
| ctx.restore(); | |
| } | |
| // active/hover hints | |
| const active = windows.find(w => w.id === activeId); | |
| if (active){ | |
| ctx.save(); | |
| ctx.strokeStyle = "#000"; | |
| ctx.setLineDash([]); | |
| ctx.lineWidth = 2; | |
| ctx.strokeRect(Math.floor(active.x)+0.5, Math.floor(active.y)+0.5, active.w-1, active.h-1); | |
| ctx.restore(); | |
| } | |
| // HUD | |
| const mode = naiveMode.checked ? "Naive" : "Regions"; | |
| const px = naiveMode.checked ? naivePixels : regionPixels; | |
| const blits = naiveMode.checked ? "(n/a)" : regionBlits; | |
| const bands = naiveMode.checked ? "(n/a)" : regionBands; | |
| let savings = ""; | |
| if (!naiveMode.checked){ | |
| // compare vs naive full-rect draw | |
| const full = windows.reduce((s,w)=>s + (w.w*w.h), 0); | |
| const pct = full ? Math.max(0, Math.round(100 - (regionPixels / full)*100)) : 0; | |
| savings = ` • saved ~${pct}% pixels vs full-rect`; | |
| } | |
| hud.textContent = `${mode} • blits: ${blits} • bands: ${bands} • pixels: ${px}${savings}`; | |
| // window list UI | |
| syncWinList(); | |
| } | |
| /* ------------------------- | |
| Window list = semantic mirror | |
| -------------------------- */ | |
| function syncWinList(){ | |
| // rebuild only if needed (small n anyway) | |
| winList.innerHTML = ""; | |
| windows.forEach((w, idx) => { | |
| const btn = document.createElement("button"); | |
| btn.className = "winBtn"; | |
| btn.type = "button"; | |
| btn.setAttribute("role", "listitem"); | |
| btn.setAttribute("aria-current", w.id === activeId ? "true" : "false"); | |
| btn.innerHTML = `<span>${w.title}</span><span>#${idx+1}</span>`; | |
| btn.addEventListener("click", () => { | |
| bringToFront(w.id); | |
| requestRender(); | |
| canvas.focus(); | |
| }); | |
| winList.appendChild(btn); | |
| }); | |
| } | |
| /* ------------------------- | |
| Z-order helpers | |
| -------------------------- */ | |
| function bringToFront(id){ | |
| const idx = windows.findIndex(w => w.id === id); | |
| if (idx < 0) return; | |
| const w = windows.splice(idx, 1)[0]; | |
| windows.push(w); | |
| activeId = id; | |
| layoutDirty = true; | |
| } | |
| function cycleActive(dir){ | |
| const idx = windows.findIndex(w => w.id === activeId); | |
| if (idx < 0) return; | |
| let next = idx + dir; | |
| if (next < 0) next = windows.length - 1; | |
| if (next >= windows.length) next = 0; | |
| activeId = windows[next].id; | |
| bringToFront(activeId); // makes cycle obvious | |
| } | |
| /* ------------------------- | |
| Pointer interaction (drag) | |
| Last-clicked comes to top. | |
| -------------------------- */ | |
| function toCanvasXY(e){ | |
| const r = canvas.getBoundingClientRect(); | |
| const x = (e.clientX - r.left) * (W / r.width); | |
| const y = (e.clientY - r.top) * (H / r.height); | |
| return { x, y }; | |
| } | |
| function topWindowAt(x,y){ | |
| for (let i=windows.length-1; i>=0; i--){ | |
| const w = windows[i]; | |
| if (x >= w.x && x <= w.x+w.w && y >= w.y && y <= w.y+w.h) return w; | |
| } | |
| return null; | |
| } | |
| function clampWindow(w){ | |
| w.x = Math.round(Math.max(0, Math.min(W - w.w, w.x))); | |
| w.y = Math.round(Math.max(0, Math.min(H - w.h, w.y))); | |
| } | |
| canvas.addEventListener("pointerdown", (e) => { | |
| const {x,y} = toCanvasXY(e); | |
| const w = topWindowAt(x,y); | |
| if (!w) return; | |
| // bring-to-front is the tribute requirement | |
| bringToFront(w.id); | |
| dragging = windows[windows.length-1]; // now top | |
| dragOffset.x = x - dragging.x; | |
| dragOffset.y = y - dragging.y; | |
| canvas.setPointerCapture(e.pointerId); | |
| layoutDirty = true; | |
| requestRender(); | |
| }); | |
| canvas.addEventListener("pointermove", (e) => { | |
| const {x,y} = toCanvasXY(e); | |
| if (dragging){ | |
| dragging.x = x - dragOffset.x; | |
| dragging.y = y - dragOffset.y; | |
| clampWindow(dragging); | |
| layoutDirty = true; | |
| requestRender(); | |
| return; | |
| } | |
| const w = topWindowAt(x,y); | |
| hoverId = w ? w.id : null; | |
| canvas.style.cursor = w ? "grab" : "default"; | |
| requestRender(); | |
| }); | |
| canvas.addEventListener("pointerup", (e) => { | |
| if (dragging){ | |
| dragging = null; | |
| layoutDirty = true; | |
| requestRender(); | |
| } | |
| try { canvas.releasePointerCapture(e.pointerId); } catch {} | |
| }); | |
| /* ------------------------- | |
| Keyboard controls | |
| -------------------------- */ | |
| canvas.addEventListener("keydown", (e) => { | |
| const active = windows.find(w => w.id === activeId); | |
| if (!active) return; | |
| const step = e.shiftKey ? 10 : 2; | |
| if (e.key === "[" ){ cycleActive(-1); e.preventDefault(); requestRender(); return; } | |
| if (e.key === "]" ){ cycleActive(+1); e.preventDefault(); requestRender(); return; } | |
| if (e.key === "Enter"){ | |
| bringToFront(activeId); | |
| e.preventDefault(); | |
| requestRender(); | |
| return; | |
| } | |
| if (e.key === "ArrowLeft"){ active.x -= step; clampWindow(active); layoutDirty=true; e.preventDefault(); requestRender(); return; } | |
| if (e.key === "ArrowRight"){ active.x += step; clampWindow(active); layoutDirty=true; e.preventDefault(); requestRender(); return; } | |
| if (e.key === "ArrowUp"){ active.y -= step; clampWindow(active); layoutDirty=true; e.preventDefault(); requestRender(); return; } | |
| if (e.key === "ArrowDown"){ active.y += step; clampWindow(active); layoutDirty=true; e.preventDefault(); requestRender(); return; } | |
| }); | |
| /* ------------------------- | |
| Control changes | |
| -------------------------- */ | |
| for (const el of [showRegions, showBands, naiveMode, scanStepSel]){ | |
| el.addEventListener("change", () => { | |
| layoutDirty = true; | |
| requestRender(); | |
| }); | |
| } | |
| /* ------------------------- | |
| Render scheduling | |
| -------------------------- */ | |
| function requestRender(){ | |
| if (rafPending) return; | |
| rafPending = true; | |
| requestAnimationFrame(() => { | |
| rafPending = false; | |
| render(); | |
| }); | |
| } | |
| /* boot */ | |
| window.addEventListener("resize", () => { | |
| // Canvas scales via CSS; internal coords stay the same. | |
| // We just re-render so overlays/HUD stay in sync. | |
| requestRender(); | |
| }, { passive: true }); | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| :root{ | |
| --bg: #d8d8d8; | |
| --panel: #cfcfcf; | |
| --paper: #efefef; | |
| --ink: #111; | |
| /* Type scale: ~1.5× */ | |
| --ui-scale: 1.5; | |
| } | |
| *{ box-sizing:border-box; } | |
| html{ | |
| font-size: 16px; /* baseline */ | |
| } | |
| body{ | |
| margin:0; | |
| background:var(--bg); | |
| color:var(--ink); | |
| font-family: Chicago, Geneva, monospace; | |
| /* 1.5× overall type scale */ | |
| font-size: calc(1rem * var(--ui-scale)); | |
| line-height: 1.25; | |
| } | |
| #app{ | |
| max-width: 1100px; | |
| margin: 0 auto; | |
| padding: 0.9rem; | |
| } | |
| .panel{ | |
| background:var(--panel); | |
| border: 0.125rem solid var(--ink); | |
| padding: 0.65rem; | |
| } | |
| .titleRow{ | |
| display:flex; | |
| align-items:baseline; | |
| justify-content:space-between; | |
| gap:0.6rem; | |
| } | |
| h1{ | |
| margin:0; | |
| font-size: 1.15rem; | |
| letter-spacing: 0.02rem; | |
| } | |
| a{ | |
| color: var(--ink); | |
| text-decoration: underline; | |
| cursor: pointer; | |
| font-size: 0.75rem; | |
| font-weight: bold; | |
| } | |
| .badge{ | |
| border: 0.125rem solid var(--ink); | |
| padding: 0.15rem 0.4rem; | |
| background: var(--paper); | |
| font-size: 0.75rem; | |
| } | |
| .sub{ | |
| margin:0.5rem 0 0.35rem; | |
| font-size:0.85rem; | |
| } | |
| .quote{ | |
| margin:0; | |
| padding:0.45rem 0.55rem; | |
| background: var(--paper); | |
| border: 0.125rem solid var(--ink); | |
| font-size:1.5rem; | |
| } | |
| .quote .note{ opacity:0.75; } | |
| .layout{ | |
| display:grid; | |
| grid-template-columns: 1fr 22rem; | |
| gap: 0.75rem; | |
| margin-top: 0.75rem; | |
| align-items:start; | |
| } | |
| /* Fully responsive: stack on narrow screens */ | |
| @media (max-width: 980px){ | |
| .layout{ | |
| grid-template-columns: 1fr; | |
| } | |
| } | |
| /* Stage panel */ | |
| .stageTop{ | |
| display:flex; | |
| justify-content:space-between; | |
| gap: 0.5rem; | |
| margin-bottom: 0.5rem; | |
| align-items:center; | |
| flex-wrap: wrap; | |
| } | |
| .kioskLabel{ | |
| font-size: 0.75rem; | |
| padding: 0.15rem 0.4rem; | |
| border: 0.125rem solid var(--ink); | |
| background: var(--paper); | |
| } | |
| .hud{ | |
| font-size: 0.75rem; | |
| text-align:right; | |
| opacity: 0.95; | |
| } | |
| /* Canvas is fully responsive */ | |
| canvas{ | |
| display:block; | |
| width: 100%; | |
| height: auto; /* keeps aspect ratio from attributes */ | |
| background: var(--paper); | |
| border: 0.18rem solid var(--ink); | |
| image-rendering: pixelated; | |
| outline: none; | |
| touch-action: none; /* better pointer interactions on mobile */ | |
| } | |
| canvas:focus-visible{ | |
| outline: 0.2rem dashed var(--ink); | |
| outline-offset: 0.3rem; | |
| } | |
| .hint{ | |
| margin-top: 0.55rem; | |
| font-size: 0.75rem; | |
| opacity: 0.9; | |
| } | |
| kbd{ | |
| border: 0.125rem solid var(--ink); | |
| background: var(--paper); | |
| padding: 0 0.25rem; | |
| font-size: 0.75rem; | |
| } | |
| /* Side panels */ | |
| .side{ | |
| display:flex; | |
| flex-direction:column; | |
| gap: 0.75rem; | |
| } | |
| .controls h2, .windows h2, .desc h2{ | |
| margin:0 0 0.5rem; | |
| font-size: 0.95rem; | |
| } | |
| .controls .row{ | |
| display:flex; | |
| align-items:center; | |
| justify-content:space-between; | |
| gap: 0.6rem; | |
| font-size: 0.85rem; | |
| margin: 0.4rem 0; | |
| } | |
| .controls select{ | |
| font-family: inherit; | |
| border: 0.125rem solid var(--ink); | |
| background: var(--paper); | |
| font-size: 0.85rem; | |
| } | |
| .windows .mini{ | |
| margin: 0 0 0.5rem; | |
| font-size: 0.75rem; | |
| opacity: 0.9; | |
| } | |
| .winList{ | |
| display:flex; | |
| flex-direction:column; | |
| gap: 0.5rem; | |
| } | |
| .winBtn{ | |
| display:flex; | |
| align-items:center; | |
| justify-content:space-between; | |
| gap: 0.6rem; | |
| width:100%; | |
| font-family: inherit; | |
| border: 0.125rem solid var(--ink); | |
| background: var(--paper); | |
| padding: 0.55rem; | |
| cursor: pointer; | |
| font-size: 0.85rem; | |
| } | |
| .winBtn[aria-current="true"]{ | |
| background: #fff; | |
| } | |
| .winBtn:focus-visible{ | |
| outline: 0.2rem dashed var(--ink); | |
| outline-offset: 0.2rem; | |
| } | |
| .desc ul{ | |
| margin: 0; | |
| padding-left: 1.1rem; | |
| font-size: 0.85rem; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment