Monorepo for Aesthetic.Computer aesthetic.computer

kidlisp: performance optimizations + layout fixes + CDP test harness

- Self-host Split.js (async load, no CDN dependency)
- Lazy-load fonts (Berkeley Mono, Google Fonts) via media="print" trick
- Debounce console scroll-to-bottom with rAF to avoid reflow per log line
- Debounce localStorage writes (250ms) and syntax highlighting (250ms)
- Remove duplicate onDidChangeModelContent handlers
- Yield to browser after Monaco DOM insert (rAF+setTimeout) for smooth splash
- Throttle tree visualization mousemove renders with rAF
- Skip already-processed tickers to avoid redundant layout thrashing
- Disable unused SVG card loading (5s bottleneck in load waterfall)
- Fix 4x4 mode: disable panel drag on desktop, clean up mobile header artifacts
- Fix settings gear position in 4x4 mode (no collapse arrow offset)
- Add will-change:transform to splash letters for GPU compositing
- Add console.clear() on live reload for cleaner dev experience
- Add layout test suite to test-kidlisp.mjs (Split.js, panels, gutters, nob)
- vscode: add CDP command server (port 19998) for test automation
- vscode: bump to v1.269.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+380 -98
+119
artery/test-kidlisp.mjs
··· 678 678 }); 679 679 } 680 680 681 + // Layout verification tests 682 + async function testLayout(harness) { 683 + await harness.runTest('Split.js loaded and panels created', async () => { 684 + const result = await harness.evaluate(`({ 685 + splitDefined: typeof Split !== 'undefined', 686 + gutterCount: document.querySelectorAll('.gutter').length, 687 + panelCount: document.querySelectorAll('.split-panel, [id$="-panel"]').length 688 + })`); 689 + info(`Split defined: ${result.splitDefined}, gutters: ${result.gutterCount}, panels: ${result.panelCount}`); 690 + if (!result.splitDefined) throw new Error('Split.js not loaded'); 691 + if (result.gutterCount < 1) throw new Error('No gutters in DOM'); 692 + }); 693 + 694 + await harness.runTest('All 4 panels visible with non-zero dimensions', async () => { 695 + const result = await harness.evaluate(` 696 + (function() { 697 + const ids = ['editor-panel', 'preview-panel', 'reference-panel', 'console-panel']; 698 + return ids.map(id => { 699 + const el = document.getElementById(id); 700 + if (!el) return { id, exists: false }; 701 + const r = el.getBoundingClientRect(); 702 + return { id, exists: true, width: Math.round(r.width), height: Math.round(r.height), top: Math.round(r.top), left: Math.round(r.left) }; 703 + }); 704 + })() 705 + `); 706 + for (const panel of result) { 707 + if (!panel.exists) throw new Error(`${panel.id} not found`); 708 + if (panel.width < 50 || panel.height < 30) throw new Error(`${panel.id} too small: ${panel.width}x${panel.height}`); 709 + info(`${panel.id}: ${panel.width}x${panel.height} @ (${panel.left}, ${panel.top})`); 710 + } 711 + }); 712 + 713 + await harness.runTest('Panels roughly fill viewport (no large gaps)', async () => { 714 + const result = await harness.evaluate(` 715 + (function() { 716 + const vw = window.innerWidth, vh = window.innerHeight; 717 + const panels = ['editor-panel', 'preview-panel', 'reference-panel', 'console-panel']; 718 + let totalArea = 0; 719 + panels.forEach(id => { 720 + const r = document.getElementById(id)?.getBoundingClientRect(); 721 + if (r) totalArea += r.width * r.height; 722 + }); 723 + const viewportArea = vw * vh; 724 + return { viewportArea, totalArea, coverage: Math.round((totalArea / viewportArea) * 100) }; 725 + })() 726 + `); 727 + info(`Panel coverage: ${result.coverage}% of viewport`); 728 + if (result.coverage < 70) throw new Error(`Panels only cover ${result.coverage}% — layout broken`); 729 + }); 730 + 731 + await harness.runTest('Center square nob positioned (desktop only)', async () => { 732 + const result = await harness.evaluate(` 733 + (function() { 734 + if (window.innerWidth <= 768) return { mobile: true }; 735 + const cs = document.getElementById('center-square'); 736 + if (!cs) return { exists: false }; 737 + const r = cs.getBoundingClientRect(); 738 + const vw = window.innerWidth, vh = window.innerHeight; 739 + return { 740 + exists: true, 741 + display: cs.style.display, 742 + x: Math.round(r.left + r.width / 2), 743 + y: Math.round(r.top + r.height / 2), 744 + viewportCenter: { x: Math.round(vw / 2), y: Math.round(vh / 2) }, 745 + distFromCenter: Math.round(Math.sqrt(Math.pow(r.left + r.width/2 - vw/2, 2) + Math.pow(r.top + r.height/2 - vh/2, 2))) 746 + }; 747 + })() 748 + `); 749 + if (result.mobile) { info('Mobile — skipping center square test'); return; } 750 + if (!result.exists) throw new Error('Center square not found'); 751 + if (result.display === 'none') throw new Error('Center square hidden'); 752 + info(`Nob at (${result.x}, ${result.y}), viewport center (${result.viewportCenter.x}, ${result.viewportCenter.y}), dist: ${result.distFromCenter}px`); 753 + // Should be within ~30% of viewport center (gutters may not be perfectly centered) 754 + const maxDist = Math.max(result.viewportCenter.x, result.viewportCenter.y) * 0.4; 755 + if (result.distFromCenter > maxDist) throw new Error(`Nob too far from center: ${result.distFromCenter}px (max ${Math.round(maxDist)}px)`); 756 + }); 757 + 758 + await harness.runTest('No mobile collapse indicators on desktop', async () => { 759 + const result = await harness.evaluate(`({ 760 + isMobile: window.innerWidth <= 768, 761 + indicatorCount: document.querySelectorAll('.mobile-collapse-indicator').length 762 + })`); 763 + if (result.isMobile) { info('Mobile — skipping'); return; } 764 + if (result.indicatorCount > 0) throw new Error(`Found ${result.indicatorCount} mobile collapse indicators on desktop`); 765 + info('No mobile collapse indicators present'); 766 + }); 767 + 768 + await harness.runTest('Settings gear positioned (not in top-left corner)', async () => { 769 + const result = await harness.evaluate(` 770 + (function() { 771 + const gear = document.getElementById('settings-gear'); 772 + if (!gear) return { exists: false }; 773 + const r = gear.getBoundingClientRect(); 774 + return { exists: true, right: Math.round(window.innerWidth - r.right), top: Math.round(r.top), width: Math.round(r.width) }; 775 + })() 776 + `); 777 + if (!result.exists) throw new Error('Settings gear not found'); 778 + info(`Gear: ${result.width}px wide, ${result.right}px from right edge, ${result.top}px from top`); 779 + // In 4x4 mode gear is inside the editor panel, so check relative to panel not viewport 780 + if (result.right > 700) throw new Error(`Gear too far from right edge: ${result.right}px`); 781 + }); 782 + 783 + await harness.runTest('Gutters have non-zero dimensions', async () => { 784 + const result = await harness.evaluate(` 785 + Array.from(document.querySelectorAll('.gutter')).map((g, i) => { 786 + const r = g.getBoundingClientRect(); 787 + return { i, cls: g.className, width: Math.round(r.width), height: Math.round(r.height), top: Math.round(r.top), left: Math.round(r.left) }; 788 + }) 789 + `); 790 + for (const g of result) { 791 + const isHoriz = g.cls.includes('horizontal'); 792 + const dim = isHoriz ? g.width : g.height; 793 + if (dim < 2) throw new Error(`Gutter ${g.i} (${g.cls}) has zero ${isHoriz ? 'width' : 'height'}`); 794 + info(`Gutter ${g.i}: ${g.width}x${g.height} @ (${g.left}, ${g.top})`); 795 + } 796 + }); 797 + } 798 + 681 799 // ═══════════════════════════════════════════════════════════════════════════ 682 800 // Main 683 801 // ═══════════════════════════════════════════════════════════════════════════ ··· 696 814 697 815 const testSuites = { 698 816 'basic': testBasicConnectivity, 817 + 'layout': testLayout, 699 818 'editor': testEditorInteractions, 700 819 'playback': testPlaybackControls, 701 820 'ui': testUIInteractions,
+3
system/public/aesthetic.computer/dep/split.min.js
··· 1 + /*! Split.js - v1.6.0 */ 2 + !function(e,t){(e=e||self).Split=t()}(this,(function(){"use strict";var e="undefined"!=typeof window?window:null,t=null===e,n=t?void 0:e.document,i=function(){return!1},r=t?"calc":["","-webkit-","-moz-","-o-"].filter((function(e){var t=n.createElement("div");return t.style.cssText="width:"+e+"calc(9px)",!!t.style.length})).shift()+"calc",s=function(e){return"string"==typeof e||e instanceof String},o=function(e){if(s(e)){var t=n.querySelector(e);if(!t)throw new Error("Selector "+e+" did not match a DOM element");return t}return e},a=function(e,t,n){var i=e[t];return void 0!==i?i:n},u=function(e,t,n,i){if(t){if("end"===i)return 0;if("center"===i)return e/2}else if(n){if("start"===i)return 0;if("center"===i)return e/2}return e},l=function(e,t){var i=n.createElement("div");return i.className="gutter gutter-"+t,i},c=function(e,t,n){var i={};return s(t)?i[e]=t:i[e]=r+"("+t+"% - "+n+"px)",i},h=function(e,t){var n;return(n={})[e]=t+"px",n};return function(r,s){if(void 0===s&&(s={}),t)return{};var d,f,v,m,g,p,y=r;Array.from&&(y=Array.from(y));var z=o(y[0]).parentNode,b=getComputedStyle?getComputedStyle(z):null,E=b?b.flexDirection:null,S=a(s,"sizes")||y.map((function(){return 100/y.length})),L=a(s,"minSize",100),_=Array.isArray(L)?L:y.map((function(){return L})),w=a(s,"expandToMin",!1),k=a(s,"gutterSize",10),x=a(s,"gutterAlign","center"),C=a(s,"snapOffset",30),M=a(s,"dragInterval",1),U=a(s,"direction","horizontal"),O=a(s,"cursor","horizontal"===U?"col-resize":"row-resize"),D=a(s,"gutter",l),A=a(s,"elementStyle",c),B=a(s,"gutterStyle",h);function j(e,t,n,i){var r=A(d,t,n,i);Object.keys(r).forEach((function(t){e.style[t]=r[t]}))}function F(){return p.map((function(e){return e.size}))}function R(e){return"touches"in e?e.touches[0][f]:e[f]}function T(e){var t=p[this.a],n=p[this.b],i=t.size+n.size;t.size=e/this.size*i,n.size=i-e/this.size*i,j(t.element,t.size,this._b,t.i),j(n.element,n.size,this._c,n.i)}function N(e){var t,n=p[this.a],r=p[this.b];this.dragging&&(t=R(e)-this.start+(this._b-this.dragOffset),M>1&&(t=Math.round(t/M)*M),t<=n.minSize+C+this._b?t=n.minSize+this._b:t>=this.size-(r.minSize+C+this._c)&&(t=this.size-(r.minSize+this._c)),T.call(this,t),a(s,"onDrag",i)())}function q(){var e=p[this.a].element,t=p[this.b].element,n=e.getBoundingClientRect(),i=t.getBoundingClientRect();this.size=n[d]+i[d]+this._b+this._c,this.start=n[v],this.end=n[m]}function H(e){var t=function(e){if(!getComputedStyle)return null;var t=getComputedStyle(e);if(!t)return null;var n=e[g];return 0===n?null:n-="horizontal"===U?parseFloat(t.paddingLeft)+parseFloat(t.paddingRight):parseFloat(t.paddingTop)+parseFloat(t.paddingBottom)}(z);if(null===t)return e;if(_.reduce((function(e,t){return e+t}),0)>t)return e;var n=0,i=[],r=e.map((function(r,s){var o=t*r/100,a=u(k,0===s,s===e.length-1,x),l=_[s]+a;return o<l?(n+=l-o,i.push(0),l):(i.push(o-l),o)}));return 0===n?e:r.map((function(e,r){var s=e;if(n>0&&i[r]-n>0){var o=Math.min(n,i[r]-n);n-=o,s=e-o}return s/t*100}))}function I(){var t=p[this.a].element,r=p[this.b].element;this.dragging&&a(s,"onDragEnd",i)(F()),this.dragging=!1,e.removeEventListener("mouseup",this.stop),e.removeEventListener("touchend",this.stop),e.removeEventListener("touchcancel",this.stop),e.removeEventListener("mousemove",this.move),e.removeEventListener("touchmove",this.move),this.stop=null,this.move=null,t.removeEventListener("selectstart",i),t.removeEventListener("dragstart",i),r.removeEventListener("selectstart",i),r.removeEventListener("dragstart",i),t.style.userSelect="",t.style.webkitUserSelect="",t.style.MozUserSelect="",t.style.pointerEvents="",r.style.userSelect="",r.style.webkitUserSelect="",r.style.MozUserSelect="",r.style.pointerEvents="",this.gutter.style.cursor="",this.parent.style.cursor="",n.body.style.cursor=""}function W(t){if(!("button"in t)||0===t.button){var r=p[this.a].element,o=p[this.b].element;this.dragging||a(s,"onDragStart",i)(F()),t.preventDefault(),this.dragging=!0,this.move=N.bind(this),this.stop=I.bind(this),e.addEventListener("mouseup",this.stop),e.addEventListener("touchend",this.stop),e.addEventListener("touchcancel",this.stop),e.addEventListener("mousemove",this.move),e.addEventListener("touchmove",this.move),r.addEventListener("selectstart",i),r.addEventListener("dragstart",i),o.addEventListener("selectstart",i),o.addEventListener("dragstart",i),r.style.userSelect="none",r.style.webkitUserSelect="none",r.style.MozUserSelect="none",r.style.pointerEvents="none",o.style.userSelect="none",o.style.webkitUserSelect="none",o.style.MozUserSelect="none",o.style.pointerEvents="none",this.gutter.style.cursor=O,this.parent.style.cursor=O,n.body.style.cursor=O,q.call(this),this.dragOffset=R(t)-this.end}}"horizontal"===U?(d="width",f="clientX",v="left",m="right",g="clientWidth"):"vertical"===U&&(d="height",f="clientY",v="top",m="bottom",g="clientHeight"),S=H(S);var X=[];function Y(e){var t=e.i===X.length,n=t?X[e.i-1]:X[e.i];q.call(n);var i=t?n.size-e.minSize-n._c:e.minSize+n._b;T.call(n,i)}return(p=y.map((function(e,t){var n,i={element:o(e),size:S[t],minSize:_[t],i:t};if(t>0&&((n={a:t-1,b:t,dragging:!1,direction:U,parent:z})._b=u(k,t-1==0,!1,x),n._c=u(k,!1,t===y.length-1,x),"row-reverse"===E||"column-reverse"===E)){var r=n.a;n.a=n.b,n.b=r}if(t>0){var s=D(t,U,i.element);!function(e,t,n){var i=B(d,t,n);Object.keys(i).forEach((function(t){e.style[t]=i[t]}))}(s,k,t),n._a=W.bind(n),s.addEventListener("mousedown",n._a),s.addEventListener("touchstart",n._a),z.insertBefore(s,i.element),n.gutter=s}return j(i.element,i.size,u(k,0===t,t===y.length-1,x),t),t>0&&X.push(n),i}))).forEach((function(e){var t=e.element.getBoundingClientRect()[d];t<e.minSize&&(w?Y(e):e.minSize=t)})),{setSizes:function(e){var t=H(e);t.forEach((function(e,n){if(n>0){var i=X[n-1],r=p[i.a],s=p[i.b];r.size=t[n-1],s.size=e,j(r.element,r.size,i._b,r.i),j(s.element,s.size,i._c,s.i)}}))},getSizes:F,collapse:function(e){Y(p[e])},destroy:function(e,t){X.forEach((function(n){if(!0!==t?n.parent.removeChild(n.gutter):(n.gutter.removeEventListener("mousedown",n._a),n.gutter.removeEventListener("touchstart",n._a)),!0!==e){var i=A(d,n.a.size,n._b);Object.keys(i).forEach((function(e){p[n.a].element.style[e]="",p[n.b].element.style[e]=""}))}}))},parent:z,pairs:X}}})); 3 + //# sourceMappingURL=split.min.js.map
+5
system/public/kidlisp.com/css/mobile.css
··· 202 202 background: rgba(128, 128, 128, 0.25); 203 203 } 204 204 205 + /* Settings gear moves left to make room for collapse arrow */ 206 + #settings-gear { 207 + right: 32px; 208 + } 209 + 205 210 /* Collapse indicator triangles */ 206 211 .mobile-collapse-indicator { 207 212 position: absolute;
+155 -64
system/public/kidlisp.com/index.html
··· 2 2 <html> 3 3 4 4 <head> 5 + <script>console.clear();</script> 5 6 <meta charset="utf-8"> 6 7 <title>KidLisp.com</title> 7 8 ··· 23 24 <link rel="preload" href="https://aesthetic.computer/type/webfonts/ywft-processing-regular.woff2" as="font" type="font/woff2" crossorigin="anonymous"> 24 25 <link rel="preload" href="https://aesthetic.computer/type/webfonts/ywft-processing-bold.woff2" as="font" type="font/woff2" crossorigin="anonymous"> 25 26 <!-- Note: We use inline @font-face instead of external CSS which has broken relative URLs --> 26 - <link rel="stylesheet" href="https://aesthetic.computer/type/webfonts/berkeley-mono-variable.css"> 27 + <link rel="stylesheet" href="https://aesthetic.computer/type/webfonts/berkeley-mono-variable.css" media="print" onload="this.media='all'" /> 27 28 <link rel="preconnect" href="https://fonts.googleapis.com"> 28 29 <script src="https://cdn.auth0.com/js/auth0-spa-js/2.0/auth0-spa-js.production.js" async onload="window.__auth0SDKReady=true"></script> 29 30 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 30 - <link href="https://fonts.googleapis.com/css2?family=Comic+Relief:ital,wght@0,400;0,700;1,400;1,700&family=Noto+Sans+Mono:wght@400;700&display=swap" rel="stylesheet"> 31 + <link href="https://fonts.googleapis.com/css2?family=Comic+Relief:ital,wght@0,400;0,700;1,400;1,700&family=Noto+Sans+Mono:wght@400;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" /> 31 32 <!-- Flag icons for language selector (lazy-loaded — not needed at startup) --> 32 33 <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/lipis/flag-icons@7.2.3/css/flag-icons.min.css" media="print" onload="this.media='all'" /> 33 34 <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" /> 34 - <!-- Modular CSS Architecture --> 35 - <link rel="stylesheet" href="/kidlisp.com/css/main.css" /> 35 + <!-- Modular CSS Architecture (parallel <link> tags instead of chained @import) --> 36 + <link rel="stylesheet" href="/kidlisp.com/css/variables.css" /> 37 + <link rel="stylesheet" href="/kidlisp.com/css/layout.css" /> 38 + <link rel="stylesheet" href="/kidlisp.com/css/components.css" /> 39 + <link rel="stylesheet" href="/kidlisp.com/css/splits.css" /> 40 + <link rel="stylesheet" href="/kidlisp.com/css/branding.css" /> 41 + <link rel="stylesheet" href="/kidlisp.com/css/editor.css" /> 42 + <link rel="stylesheet" href="/kidlisp.com/css/keeps.css" /> 43 + <link rel="stylesheet" href="/kidlisp.com/css/mobile.css" /> 36 44 <style> 37 45 /* iOS Safari font fix - explicit absolute URLs with font-display: swap */ 38 46 @font-face { ··· 561 569 562 570 #settings-gear { 563 571 position: absolute; 564 - right: 32px; /* Left of the collapse arrow at right: 12px */ 572 + right: 12px; /* Default position (desktop / 4x4 — no collapse arrow) */ 565 573 top: 50%; 566 574 transform: translateY(-50%); 567 575 width: 16px; ··· 7998 8006 display: inline-block; 7999 8007 animation: letterWiggle 0.4s ease-in-out infinite; 8000 8008 text-shadow: 2px 2px 0px rgba(0, 0, 0, 0.2); 8009 + will-change: transform; 8001 8010 } 8002 8011 8003 8012 .splash-letter:nth-child(1) { animation-delay: 0ms; } ··· 9598 9607 </div> 9599 9608 </div> 9600 9609 9601 - <!-- Split.js --> 9602 - <script src="https://cdnjs.cloudflare.com/ajax/libs/split.js/1.6.0/split.min.js"></script> 9610 + <!-- Split.js (async, self-hosted, UMD patched to always set window.Split) --> 9611 + <script src="/aesthetic.computer/dep/split.min.js" async></script> 9603 9612 9604 9613 <!-- Monaco Editor ESM - using jsDelivr for modern ESM support --> 9605 9614 <script type="module"> ··· 10526 10535 }, 10527 10536 }; 10528 10537 10529 - // SVG cards generated from LaTeX (in /cards/svg/) 10530 - const svgCards = [ 10531 - 'drawing-line', 10532 - 'drawing-box', 10533 - 'colors-ink', 10534 - 'colors-wipe', 10535 - ]; 10538 + // SVG cards generated from LaTeX (in /cards/svg/) — disabled for now 10539 + const svgCards = []; 10536 10540 10537 10541 const cardDecks = { 10538 10542 'aesthetic-computer': [ ··· 11513 11517 syncUiModeClass(); 11514 11518 11515 11519 function initSplits() { 11520 + // Guard: Split.js might not be loaded yet (async script) 11521 + if (typeof Split === 'undefined' && window.innerWidth > 768) { 11522 + console.log('[layout] initSplits: Split.js not loaded yet, skipping'); 11523 + return; 11524 + } 11525 + console.log('[layout] initSplits: running, isMobile:', window.innerWidth <= 768, 'Split defined:', typeof Split !== 'undefined'); 11526 + 11516 11527 // Destroy existing splits 11517 11528 splits.forEach(s => s.destroy()); 11518 11529 splits = []; 11519 - 11530 + 11520 11531 const isMobile = window.innerWidth <= 768; 11521 11532 const mainSplit = document.getElementById('main-split'); 11522 11533 let topRow = document.getElementById('top-row'); ··· 11620 11631 11621 11632 // Reset mobile headers flag so they can be re-initialized when returning to mobile 11622 11633 mobileHeadersInitialized = false; 11623 - 11634 + 11624 11635 // Clear mobile-mode inline styles from all panels so Split.js can control them 11625 11636 [editorPanel, previewPanel, consolePanel, referencePanel].forEach(panel => { 11626 11637 if (panel) { ··· 11631 11642 delete panel.dataset.collapsed; 11632 11643 delete panel.dataset.savedHeight; 11633 11644 delete panel.dataset.savedFlex; 11645 + 11646 + // Clean up mobile header artifacts (inline cursor, collapse indicators) 11647 + const headers = panel.querySelectorAll('.editor-header, .docs-header'); 11648 + headers.forEach(h => { 11649 + h.style.cursor = ''; 11650 + h.style.userSelect = ''; 11651 + h.style.webkitUserSelect = ''; 11652 + h.style.touchAction = ''; 11653 + h.classList.remove('dragging'); 11654 + }); 11655 + const indicators = panel.querySelectorAll('.mobile-collapse-indicator'); 11656 + indicators.forEach(ind => ind.remove()); 11634 11657 } 11635 11658 }); 11636 11659 ··· 12417 12440 // Touch events on header (entire header is draggable) 12418 12441 // Even if touching interactive elements, allow drag - just suppress their click 12419 12442 header.addEventListener('touchstart', (e) => { 12443 + // Only allow drag in mobile/single-column mode 12444 + if (window.innerWidth > 768) return; 12445 + 12420 12446 const startedOnInteractive = shouldSkipDrag(e.target); 12421 12447 const targetElement = e.target; 12422 - 12448 + 12423 12449 // Only stop propagation if NOT on interactive element 12424 12450 // (let interactive elements handle their own events normally) 12425 12451 if (!startedOnInteractive) { ··· 12469 12495 // Mouse events on header (entire header is draggable) 12470 12496 // Even if clicking interactive elements, allow drag - just suppress their click 12471 12497 header.addEventListener('mousedown', (e) => { 12498 + // Only allow drag in mobile/single-column mode 12499 + if (window.innerWidth > 768) return; 12500 + 12472 12501 const startedOnInteractive = shouldSkipDrag(e.target); 12473 12502 const targetElement = e.target; 12474 - 12503 + 12475 12504 // Only stop propagation if NOT on interactive element 12476 12505 if (!startedOnInteractive) { 12477 12506 e.stopPropagation(); ··· 12520 12549 }); 12521 12550 } 12522 12551 12523 - // Initialize on load 12524 - initSplits(); 12552 + // ── Global Init Pipeline ── 12553 + // Wait for async deps, then run init stages in order. 12554 + function whenReady(checkFn) { 12555 + return new Promise(resolve => { 12556 + (function poll() { 12557 + if (checkFn()) return resolve(); 12558 + setTimeout(poll, 50); 12559 + })(); 12560 + }); 12561 + } 12562 + 12563 + // Stage 1: Split.js loaded (or mobile — no Split needed) 12564 + console.log('[layout] pipeline started, width:', window.innerWidth, 'Split defined:', typeof Split !== 'undefined'); 12565 + whenReady(() => window.innerWidth <= 768 || typeof Split !== 'undefined') 12566 + .then(() => { 12567 + console.log('[layout] stage 1: Split.js ready, calling initSplits()'); 12568 + initSplits(); 12569 + console.log('[layout] initSplits done, splits.length:', splits.length, 'gutters:', document.querySelectorAll('.gutter').length); 12570 + if (window.innerWidth <= 768) return; // No center square on mobile 12571 + // Stage 2: Wait for gutters to have real dimensions 12572 + return whenReady(() => { 12573 + const g = document.querySelector('.gutter'); 12574 + if (!g) return false; 12575 + const r = g.getBoundingClientRect(); 12576 + return r.width > 0 || r.height > 0; 12577 + }); 12578 + }) 12579 + .then(() => { 12580 + if (window.innerWidth <= 768) return; 12581 + console.log('[layout] stage 2: gutters laid out'); 12582 + // Stage 3: One frame for layout to settle, then place the nob 12583 + return new Promise(resolve => requestAnimationFrame(resolve)); 12584 + }) 12585 + .then(() => { 12586 + if (window.innerWidth <= 768) return; 12587 + console.log('[layout] stage 3: placing center square'); 12588 + updateCenterSquarePosition(); 12589 + setupCenterSquareDrag(); 12590 + 12591 + const observer = new MutationObserver(() => updateCenterSquarePosition()); 12592 + const mainSplit = document.getElementById('main-split'); 12593 + if (mainSplit) { 12594 + observer.observe(mainSplit, { 12595 + attributes: true, 12596 + attributeFilter: ['style'], 12597 + subtree: true 12598 + }); 12599 + } 12600 + }); 12525 12601 12526 12602 // Editor auto-height for single column mode 12527 12603 // Starts enabled, disabled once user manually drags the editor panel ··· 12814 12890 document.addEventListener('pointerup', endDrag); 12815 12891 } 12816 12892 12817 - // Initialize everything 12818 - setTimeout(() => { 12819 - updateCenterSquarePosition(); 12820 - setupCenterSquareDrag(); 12821 - 12822 - // Update center square position when splits are dragged 12823 - const observer = new MutationObserver(() => { 12824 - updateCenterSquarePosition(); 12825 - }); 12826 - 12827 - const mainSplit = document.getElementById('main-split'); 12828 - if (mainSplit) { 12829 - observer.observe(mainSplit, { 12830 - attributes: true, 12831 - attributeFilter: ['style'], 12832 - subtree: true 12833 - }); 12834 - } 12835 - }, 500); 12893 + // Center square init is handled by the Global Init Pipeline above. 12836 12894 12837 12895 import { tokenize } from 'https://aesthetic.computer/aesthetic.computer/lib/kidlisp.mjs'; 12838 12896 import { KidLisp } from 'https://aesthetic.computer/aesthetic.computer/lib/kidlisp.mjs'; ··· 13219 13277 }, 100); 13220 13278 } 13221 13279 13280 + // Batch console scroll-to-bottom to avoid forced reflow on every log line 13281 + let _consoleScrollRafPending = false; 13282 + function scheduleConsoleScroll() { 13283 + if (_consoleScrollRafPending) return; 13284 + _consoleScrollRafPending = true; 13285 + requestAnimationFrame(() => { 13286 + _consoleScrollRafPending = false; 13287 + const co = document.getElementById('console-output'); 13288 + if (co) co.scrollTop = co.scrollHeight; 13289 + const mc = document.getElementById('mobile-console-output'); 13290 + if (mc) mc.scrollTop = mc.scrollHeight; 13291 + }); 13292 + } 13293 + 13222 13294 // 📸 Add console image entry from snap function 13223 13295 function addConsoleImageEntry(data) { 13224 13296 const { imageDataUrl, frameCount, timestamp, dimensions, pieceCode, pieceLabel, filename, userHandle, embeddedSource } = data; ··· 13308 13380 if (mugBtn) entry.appendChild(mugBtn); 13309 13381 13310 13382 consoleOutput.appendChild(entry); 13311 - consoleOutput.scrollTop = consoleOutput.scrollHeight; 13312 - 13383 + 13313 13384 // Also sync to mobile console (clone the entry and re-attach mug button handler) 13314 13385 const mobileConsole = document.getElementById('mobile-console-output'); 13315 13386 if (mobileConsole) { ··· 13325 13396 clonedMugBtn.addEventListener('mouseleave', () => { clonedMugBtn.style.opacity = '0.85'; clonedMugBtn.style.transform = 'scale(1)'; clonedMugBtn.style.background = 'rgba(139, 90, 43, 0.8)'; }); 13326 13397 } 13327 13398 mobileConsole.appendChild(clone); 13328 - mobileConsole.scrollTop = mobileConsole.scrollHeight; 13329 13399 } 13400 + scheduleConsoleScroll(); 13330 13401 } 13331 - 13402 + 13332 13403 // Generate timestamped filename like bios.mjs does 13333 13404 function generateConsoleImageFilename(pieceCode) { 13334 13405 const d = new Date(); ··· 14993 15064 } 14994 15065 14995 15066 consoleOutput.appendChild(entry); 14996 - consoleOutput.scrollTop = consoleOutput.scrollHeight; 14997 - 15067 + 14998 15068 // Also sync to mobile console (clone the entry) 14999 15069 const mobileConsole = document.getElementById('mobile-console-output'); 15000 15070 if (mobileConsole) { 15001 15071 const clone = entry.cloneNode(true); 15002 15072 mobileConsole.appendChild(clone); 15003 - mobileConsole.scrollTop = mobileConsole.scrollHeight; 15004 15073 } 15005 - 15074 + scheduleConsoleScroll(); 15075 + 15006 15076 return entry; 15007 15077 } 15008 15078 ··· 15599 15669 15600 15670 // Make editor globally accessible for other parts of the page 15601 15671 window.editor = editor; 15602 - 15672 + 15673 + // --- YIELD TO BROWSER --- 15674 + // Editor DOM is now in the page. Yield so the browser can paint it and 15675 + // keep splash / CSS animations smooth on lower-end CPUs. 15676 + // Use requestAnimationFrame to let the browser render the editor DOM 15677 + // before continuing with heavyweight init (KIDLISP_DOCS, event handlers). 15678 + requestAnimationFrame(() => { setTimeout(() => { 15679 + 15603 15680 // ==================== KIDLISP DOCUMENTATION CONTEXT MENU ==================== 15604 15681 // Custom right-click context menu for KidLisp documentation lookup 15605 15682 ··· 16665 16742 updateStageContext(); 16666 16743 16667 16744 // ==================== END KIDLISP DOCUMENTATION CONTEXT MENU ==================== 16668 - 16745 + 16669 16746 // Log editor initialization 16670 16747 logEditorInit('Monaco', { theme: initialTheme, language: 'kidlisp' }); 16671 16748 logBoot('editor', 'Monaco ready'); ··· 17039 17116 } 17040 17117 window.decorationTimeout = setTimeout(() => { 17041 17118 syntaxHighlighter.applyDecorations(true); 17042 - }, 50); 17043 - // Don't save scrambled animation content to localStorage 17044 - if (!(window.isDecrypting && window.isDecrypting())) { 17045 - localStorage.setItem('kidlisp-code', editor.getValue()); 17119 + }, 250); 17120 + // Debounce localStorage writes to avoid blocking the main thread on every keystroke 17121 + if (window.localStorageSaveTimeout) { 17122 + clearTimeout(window.localStorageSaveTimeout); 17046 17123 } 17124 + window.localStorageSaveTimeout = setTimeout(() => { 17125 + // Don't save scrambled animation content to localStorage 17126 + if (!(window.isDecrypting && window.isDecrypting())) { 17127 + localStorage.setItem('kidlisp-code', editor.getValue()); 17128 + } 17129 + }, 500); 17047 17130 17048 17131 // Clear URL and code identifier when content changes 17049 17132 const currentPath = window.location.pathname; ··· 17082 17165 }); 17083 17166 } 17084 17167 17085 - editor.onDidChangeModelContent(() => { 17086 - updateLineNumbers(); 17087 - }); 17088 - 17089 17168 updateLineNumbers(); 17090 17169 17091 17170 // Update preview on Cmd/Ctrl+Enter (play/run) ··· 17219 17298 }); 17220 17299 } 17221 17300 17222 - // Update clear button visibility on content change 17301 + // Update clear/slide button visibility on content change 17302 + // Note: updateLineNumbers and applyDecorations are already handled by other 17303 + // onDidChangeModelContent handlers with proper debouncing — don't duplicate here. 17223 17304 editor.onDidChangeModelContent(() => { 17224 - updateLineNumbers(); 17225 - syntaxHighlighter.applyDecorations(true); 17226 17305 updateClearButtonVisibility(); 17227 - // Also update slide button visibility (depends on whether code has numbers) 17228 17306 if (window.updateSlideButtonVisibility) window.updateSlideButtonVisibility(); 17229 17307 }); 17230 17308 ··· 17965 18043 if (window.triggerSplashAnimation) { 17966 18044 window.triggerSplashAnimation(); 17967 18045 } 18046 + 18047 + }, 0); }); // end deferred init (rAF + setTimeout yield) 17968 18048 }); 17969 18049 17970 18050 let isInitialLoad = true; ··· 21631 21711 function processTickerAnimations(container) { 21632 21712 const tickers = container.querySelectorAll('.ticker-wrapper[data-ticker-content]'); 21633 21713 tickers.forEach(ticker => { 21714 + // Skip tickers already processed (avoids redundant layout thrashing) 21715 + if (ticker.classList.contains('ticker-scroll') || ticker.classList.contains('ticker-static')) return; 21716 + 21634 21717 const parent = ticker.closest('.example-code'); 21635 21718 if (!parent) return; 21636 - 21719 + 21637 21720 // Get the encoded original content 21638 21721 const encodedContent = ticker.getAttribute('data-ticker-content'); 21639 21722 if (!encodedContent) return; 21640 - 21723 + 21641 21724 // Temporarily allow overflow to get true content width 21642 21725 const originalOverflow = parent.style.overflow; 21643 21726 parent.style.overflow = 'visible'; ··· 22275 22358 canvas.style.cursor = 'grabbing'; 22276 22359 }); 22277 22360 22361 + let treeRafPending = false; 22278 22362 canvas.addEventListener('mousemove', (e) => { 22279 22363 if (!dragging) return; 22280 22364 vizSystem.tree.pan.x += e.clientX - lastX; 22281 22365 vizSystem.tree.pan.y += e.clientY - lastY; 22282 22366 lastX = e.clientX; 22283 22367 lastY = e.clientY; 22284 - renderTree(); 22368 + if (!treeRafPending) { 22369 + treeRafPending = true; 22370 + requestAnimationFrame(() => { 22371 + treeRafPending = false; 22372 + renderTree(); 22373 + }); 22374 + } 22285 22375 }); 22286 22376 22287 22377 canvas.addEventListener('mouseup', () => { ··· 24365 24455 if (msg.content.piece === "*refresh*" || msg.content.piece === "kidlisp.com") { 24366 24456 log.socket.log("Reloading entire page..."); 24367 24457 setTimeout(() => { 24458 + console.clear(); 24368 24459 window.location.reload(); 24369 24460 }, 150); // Wait a moment for backend to be ready 24370 24461 }
+45 -29
system/public/kidlisp.com/js/monaco-kidlisp-highlighting.mjs
··· 20 20 this.isUpdating = false; 21 21 this.updateLoopRunning = false; 22 22 23 + // Tokenization cache — avoid re-tokenizing when code hasn't changed 24 + this._cachedCode = ''; 25 + this._cachedTokens = null; 26 + this._cachedTokenPositions = null; 27 + 23 28 // Options 24 29 this.enableTimingBlinks = options.enableTimingBlinks !== false; 25 30 this.lightModeHighContrast = options.lightModeHighContrast ?? false; ··· 111 116 } 112 117 113 118 try { 114 - // Use KidLisp's tokenizer 115 - const tokens = tokenize(code); 119 + // Use cached tokenization if code hasn't changed (avoids expensive re-tokenize during blinks) 120 + let tokens, tokenPositions; 121 + if (code === this._cachedCode && this._cachedTokens) { 122 + tokens = this._cachedTokens; 123 + tokenPositions = this._cachedTokenPositions; 124 + } else { 125 + tokens = tokenize(code); 126 + // Pre-compute token positions so we don't redo string scanning each frame 127 + tokenPositions = []; 128 + let searchPos = 0; 129 + for (const token of tokens) { 130 + const tokenPos = code.indexOf(token, searchPos); 131 + if (tokenPos === -1) { 132 + tokenPositions.push(null); 133 + continue; 134 + } 135 + const beforeToken = code.substring(0, tokenPos); 136 + const lines = beforeToken.split('\n'); 137 + const lineNumber = lines.length; 138 + const columnNumber = lines[lines.length - 1].length + 1; 139 + const endColumn = columnNumber + token.length; 140 + tokenPositions.push({ lineNumber, columnNumber, endColumn }); 141 + searchPos = tokenPos + token.length; 142 + } 143 + this._cachedCode = code; 144 + this._cachedTokens = tokens; 145 + this._cachedTokenPositions = tokenPositions; 146 + } 147 + 116 148 this.kidlisp.initializeSyntaxHighlighting(code); 117 149 118 - // Track position in code 119 - let searchPos = 0; 120 - 121 150 tokens.forEach((token, index) => { 151 + const pos = tokenPositions[index]; 152 + if (!pos) return; 153 + 122 154 // Get the color from KidLisp's getTokenColor method 123 155 let colorName = this.kidlisp.getTokenColor(token, tokens, index); 124 156 125 - // Find the token's position in the code starting from searchPos 126 - const tokenPos = code.indexOf(token, searchPos); 127 - if (tokenPos === -1) return; 128 - 129 - // Calculate line and column from position 130 - const beforeToken = code.substring(0, tokenPos); 131 - const lines = beforeToken.split('\n'); 132 - const lineNumber = lines.length; 133 - const columnNumber = lines[lines.length - 1].length + 1; 134 - const endColumn = columnNumber + token.length; 157 + const { lineNumber, columnNumber, endColumn } = pos; 135 158 136 159 // Validate range 137 - if (lineNumber < 1 || columnNumber < 1 || endColumn < columnNumber) { 138 - searchPos = tokenPos + token.length; 139 - return; 140 - } 160 + if (lineNumber < 1 || columnNumber < 1 || endColumn < columnNumber) return; 141 161 142 162 // Handle special coloring cases 143 163 this._handleTokenDecoration(token, colorName, decorations, lineNumber, columnNumber, endColumn); 144 - 145 - searchPos = tokenPos + token.length; 146 164 }); 147 165 148 166 } catch (error) { ··· 365 383 if (!this.isUpdating && this.editor && this.editor.getModel()) { 366 384 const animating = shouldAnimate(); 367 385 if (animating) { 368 - // Playing — update at 60fps for timing blinks 369 - requestAnimationFrame(() => { 370 - this.kidlisp.isEditMode = true; 371 - this.applyDecorations(true); 372 - if (window.perfCounters) window.perfCounters.decorations++; 373 - lastAnimating = true; 374 - setTimeout(scheduleUpdate, 16); 375 - }); 386 + // Playing — update at ~20fps for timing blinks (50ms; visually sufficient) 387 + this.kidlisp.isEditMode = true; 388 + this.applyDecorations(true); 389 + if (window.perfCounters) window.perfCounters.decorations++; 390 + lastAnimating = true; 391 + setTimeout(scheduleUpdate, 50); 376 392 } else { 377 393 // Not playing — only redecorate when code changes or transitioning from playing 378 394 const currentCode = this.editor.getModel().getValue();
+50 -2
vscode-extension/extension.ts
··· 16 16 // This provides Monaco-parity highlighting for .lisp files in VS Code 17 17 import * as KidLispSyntax from "./kidlisp-syntax"; 18 18 19 - // Dynamically import path, fs, and child_process to ensure web compatibility. 20 - let path: any, fs: any, cp: any; 19 + // Dynamically import path, fs, child_process, and http to ensure web compatibility. 20 + let path: any, fs: any, cp: any, http: any; 21 21 let _modulesReady: Promise<void>; 22 22 _modulesReady = (async () => { 23 23 if (typeof window === "undefined") { 24 24 path = await import("path"); 25 25 fs = await import("fs"); 26 26 cp = await import("child_process"); 27 + http = await import("http"); 27 28 } 28 29 })(); 29 30 ··· 2100 2101 } 2101 2102 } 2102 2103 }); 2104 + 2105 + // 🌐 CDP Command Server — local HTTP endpoint for test harnesses and automation 2106 + if (http) { 2107 + const CDP_PORT = 19998; 2108 + const cdpServer = http.createServer(async (req: any, res: any) => { 2109 + res.setHeader("Access-Control-Allow-Origin", "*"); 2110 + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); 2111 + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); 2112 + if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; } 2113 + 2114 + if (req.url === "/status") { 2115 + res.writeHead(200, { "Content-Type": "application/json" }); 2116 + res.end(JSON.stringify({ ok: true, extension: "aesthetic-computer" })); 2117 + return; 2118 + } 2119 + 2120 + if (req.url === "/command" && req.method === "POST") { 2121 + const body: Buffer[] = []; 2122 + req.on("data", (chunk: Buffer) => body.push(chunk)); 2123 + req.on("end", async () => { 2124 + try { 2125 + const { command, args } = JSON.parse(Buffer.concat(body).toString()); 2126 + const result = await vscode.commands.executeCommand(command, ...(args || [])); 2127 + res.writeHead(200, { "Content-Type": "application/json" }); 2128 + res.end(JSON.stringify({ ok: true, result: result ?? null })); 2129 + } catch (e: any) { 2130 + res.writeHead(500, { "Content-Type": "application/json" }); 2131 + res.end(JSON.stringify({ ok: false, error: e.message })); 2132 + } 2133 + }); 2134 + return; 2135 + } 2136 + 2137 + res.writeHead(404); 2138 + res.end("Not found"); 2139 + }); 2140 + 2141 + cdpServer.listen(CDP_PORT, "127.0.0.1", () => { 2142 + console.log(`🌐 CDP command server listening on http://127.0.0.1:${CDP_PORT}`); 2143 + }); 2144 + cdpServer.on("error", (e: any) => { 2145 + if (e.code === "EADDRINUSE") { 2146 + console.log(`🌐 CDP command server port ${CDP_PORT} already in use, skipping`); 2147 + } 2148 + }); 2149 + context.subscriptions.push({ dispose: () => cdpServer.close() }); 2150 + } 2103 2151 } 2104 2152 2105 2153 // 📓 Documentation
+2 -2
vscode-extension/package-lock.json
··· 1 1 { 2 2 "name": "aesthetic-computer-code", 3 - "version": "1.267.1", 3 + "version": "1.269.0", 4 4 "lockfileVersion": 3, 5 5 "requires": true, 6 6 "packages": { 7 7 "": { 8 8 "name": "aesthetic-computer-code", 9 - "version": "1.267.1", 9 + "version": "1.269.0", 10 10 "license": "None", 11 11 "dependencies": { 12 12 "acorn": "^8.15.0",
+1 -1
vscode-extension/package.json
··· 4 4 "displayName": "Aesthetic Computer", 5 5 "icon": "resources/icon.png", 6 6 "author": "Jeffrey Alan Scudder", 7 - "version": "1.268.0", 7 + "version": "1.269.0", 8 8 "description": "Code, run, and publish your pieces. Includes Aesthetic Computer themes and KidLisp syntax highlighting.", 9 9 "engines": { 10 10 "vscode": "^1.105.0"