diff --git a/.github/workflows/gates.yml b/.github/workflows/gates.yml index 7a5a969..b6f8da3 100644 --- a/.github/workflows/gates.yml +++ b/.github/workflows/gates.yml @@ -40,6 +40,10 @@ jobs: run: node continuity-audit-corpus.js - name: Theme and accessibility structure gate run: node theme-gate.js + - name: Garden deer pose geometry and motion lifecycle + run: | + node governance/harnesses/verify-garden-deer.js + node governance/harnesses/verify-garden-deer-runtime.js - name: Day-ledger corpus (dual attestation, chain integrity) run: node day-ledger-corpus.js - name: Benchmark (scale limits stay reproducible) diff --git a/assets/garden-deer-alert-v1.webp b/assets/garden-deer-alert-v1.webp new file mode 100644 index 0000000..dee46e6 Binary files /dev/null and b/assets/garden-deer-alert-v1.webp differ diff --git a/assets/garden-deer-mother-v1.webp b/assets/garden-deer-mother-v1.webp new file mode 100644 index 0000000..914b655 Binary files /dev/null and b/assets/garden-deer-mother-v1.webp differ diff --git a/assets/garden-deer-pose-v1.js b/assets/garden-deer-pose-v1.js new file mode 100644 index 0000000..93d07f1 --- /dev/null +++ b/assets/garden-deer-pose-v1.js @@ -0,0 +1,127 @@ +/* Pure image-space deer posing. Coordinates use the original bitmap, Y downward. + * advance() stores linear progress; transformPoint() applies smooth() internally. + * No DOM, timers, storage, image loading, or renderer dependencies. + */ +(function (root, factory) { + if (typeof module === 'object' && module.exports) module.exports = factory(); + else root.GardenDeerPose = factory(); +})(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + + function finite(value, name) { + if (!Number.isFinite(value)) throw new TypeError(name + ' must be finite.'); + return value; + } + function clamp(value) { return Math.max(0, Math.min(1, value)); } + function smooth(value) { + const t = clamp(finite(value, 'pose')); + return t * t * (3 - 2 * t); + } + function ramp(start, end, value) { return smooth((value - start) / (end - start)); } + + function stateTarget(state, currentTarget = 0) { + if (state === 'running') return 1; + if (['paused', 'reduced-motion', 'error', 'unavailable', 'context-lost'].indexOf(state) >= 0) return 0; + return currentTarget; + } + + // A duration is the time for the complete 0-to-1 travel. Reversing the target + // continues from the current pose; it does not start a new easing timeline. + function advance(current, target, dtSeconds, durationSeconds = 1) { + const from = clamp(finite(current, 'current')); + const to = clamp(finite(target, 'target')); + finite(dtSeconds, 'dtSeconds'); + finite(durationSeconds, 'durationSeconds'); + if (dtSeconds <= 0 || from === to) return from; + if (durationSeconds <= 0) return to; + const distance = Math.min(Math.abs(to - from), dtSeconds / durationSeconds); + return from + Math.sign(to - from) * distance; + } + + function rotate(x, y, pivot, angle) { + const dx = x - pivot[0], dy = y - pivot[1]; + const c = Math.cos(angle), s = Math.sin(angle); + return [pivot[0] + dx * c - dy * s, pivot[1] + dx * s + dy * c]; + } + + function mixPoint(a, b, weight) { + return [a[0] + (b[0] - a[0]) * weight, a[1] + (b[1] - a[1]) * weight]; + } + + // Position and tangent of the bent centerline. Thirds make its zero-angle + // limit exactly the original segment, including points between the endpoints. + function neckCurve(root, head, angle, s) { + const dx = head[0] - root[0], dy = head[1] - root[1]; + const p1 = rotate(root[0] + dx / 3, root[1] + dy / 3, root, angle * 0.55); + const p2 = rotate(root[0] + dx * 2 / 3, root[1] + dy * 2 / 3, root, angle * 0.84); + const p3 = rotate(head[0], head[1], root, angle); + const r = 1 - s; + return [ + r * r * r * root[0] + 3 * r * r * s * p1[0] + 3 * r * s * s * p2[0] + s * s * s * p3[0], + r * r * r * root[1] + 3 * r * r * s * p1[1] + 3 * r * s * s * p2[1] + s * s * s * p3[1], + 3 * r * r * (p1[0] - root[0]) + 6 * r * s * (p2[0] - p1[0]) + 3 * s * s * (p3[0] - p2[0]), + 3 * r * r * (p1[1] - root[1]) + 6 * r * s * (p2[1] - p1[1]) + 3 * s * s * (p3[1] - p2[1]) + ]; + } + + // Skinning weights blend from the shoulder into the neck, then into a rigid + // skull region. Positive angles are clockwise in these top-down coordinates. + // Returns [x,y]. The original torso/legs remain stationary; no global tilt. + function transformPoint(x, y, pose, rig) { + finite(x, 'x'); finite(y, 'y'); + const amount = smooth(pose); + if (amount === 0) return [x, y]; + if (!rig || !Array.isArray(rig.root) || !Array.isArray(rig.head)) throw new TypeError('rig needs root and head pairs.'); + const root = [finite(rig.root[0], 'root.x'), finite(rig.root[1], 'root.y')]; + const head = [finite(rig.head[0], 'head.x'), finite(rig.head[1], 'head.y')]; + const neckAngle = finite(rig.neckAngle, 'neckAngle') * amount; + const headAngle = finite(rig.headAngle, 'headAngle') * smooth((pose - 0.08) / 0.92); + const dx = head[0] - root[0], dy = head[1] - root[1]; + const lengthSquared = dx * dx + dy * dy; + if (!(lengthSquared > 0) || !Number.isFinite(lengthSquared)) throw new RangeError('rig root and head must be distinct finite points.'); + + // Exact anchors include the requested rear-body and lower-leg exclusion. + // The vertical ramp also plants everything at or below the neck root. + if (x <= root[0] - 120 || y >= root[1]) return [x, y]; + const along = ((x - root[0]) * dx + (y - root[1]) * dy) / lengthSquared; + const shoulder = ramp(root[0] - 120, root[0] + 40, x); + const aboveBody = 1 - ramp(root[1] - 80, root[1], y); + const neckWeight = ramp(0.05, 0.72, along) * shoulder * aboveBody; + if (neckWeight === 0) return [x, y]; + + // The muzzle extends right of the skull pivot and the ears extend above it. + // Keep that whole region rigid, with a soft join behind/below the head; + // a radial cutoff would let the nose rotate differently from the skull. + const headWeight = ramp(head[0] - 190, head[0] - 90, x) + * (1 - ramp(head[1] + 40, head[1] + 150, y)) + * ramp(0.85, 1, neckWeight); + const headPivot = rotate(head[0], head[1], root, neckAngle); + const skullOffset = rotate(x, y, head, neckAngle + headAngle); + const skullPoint = [headPivot[0] + skullOffset[0] - head[0], headPivot[1] + skullOffset[1] - head[1]]; + if (headWeight === 1) return skullPoint; + + const length = Math.sqrt(lengthSquared), s = clamp(along); + const curve = neckCurve(root, head, neckAngle, s); + const tangentLength = Math.hypot(curve[2], curve[3]); + const tx = tangentLength > 1e-8 ? curve[2] / tangentLength : dx / length; + const ty = tangentLength > 1e-8 ? curve[3] / tangentLength : dy / length; + const side = ((x - root[0]) * -dy + (y - root[1]) * dx) / length; + const extension = (along - s) * length; + // Carry thickness along the local normal rather than stretching it along + // a rotating arm. Blend at the planted shoulder and at the rigid skull. + const mapped = [curve[0] + tx * extension - ty * side, curve[1] + ty * extension + tx * side]; + const neckPoint = mixPoint([x, y], mapped, neckWeight); + return mixPoint(neckPoint, skullPoint, headWeight); + } + + function coverCentered(imageWidth, imageHeight, boxWidth, boxHeight) { + const sizes = [imageWidth, imageHeight, boxWidth, boxHeight]; + sizes.forEach(value => { + if (!Number.isFinite(value) || value <= 0) throw new RangeError('Image and box dimensions must be finite and positive.'); + }); + const scale = Math.max(boxWidth / imageWidth, boxHeight / imageHeight); + return { scale, left: (boxWidth - imageWidth * scale) / 2, top: (boxHeight - imageHeight * scale) / 2 }; + } + + return Object.freeze({ stateTarget, advance, smooth, transformPoint, coverCentered }); +}); diff --git a/assets/garden-deer-v1.js b/assets/garden-deer-v1.js new file mode 100644 index 0000000..3409aab --- /dev/null +++ b/assets/garden-deer-v1.js @@ -0,0 +1,203 @@ +/* Optional garden wildlife. One small WebGL1 canvas; sleep between short poses. + * The water renderer owns motion state. This module never changes that state. + * Source artwork uses a chroma matte, composited once into local alpha textures. + * A Canvas2D upright fallback remains available independently of WebGL. + */ +(() => { + 'use strict'; + const M = window.GardenDeerPose; + const layer = document.getElementById('garden-deer'); + const scene = document.getElementById('garden-scene'); + const water = document.getElementById('garden-water'); + if (!M || !layer || !scene || !water || layer.dataset.initialized) return; + layer.dataset.initialized = 'true'; + const reduced = matchMedia('(prefers-reduced-motion: reduce)'); + const box = { x: 760, y: 490, width: 280, height: 225 }; + const rig = { root: [900,550], head: [1070,235], neckAngle: 1.95, headAngle: -0.85 }; + const animals = [ + { src: 'assets/garden-deer-mother-v1.webp', x: 780, y: 509, scale: .16, duration: 2.5/8, pose: 0, look: 0, raiseWait: 0, rig }, + { src: 'assets/garden-deer-young-v1.webp', x: 856, y: 592, scale: .095, duration: 2.9/8, pose: 0, look: 0, raiseWait: 0, rig }, + ]; + const fallback = document.createElement('canvas'); + const canvas = document.createElement('canvas'); + fallback.setAttribute('aria-hidden','true'); canvas.setAttribute('aria-hidden','true'); + layer.append(fallback, canvas); + canvas.hidden = true; + let ready = false, gl = null, program = null, buffer = null, index = null, uniforms = null; + let failed = false, lost = false, raf = 0, previous = 0, target = 0, dirty = true; + let frameCount = 0, cycle = 'drink', holdTimer = 0, alertImage = null, alertTexture = null; + const columns = 32, rows = 24, vertices = [], indices = []; + for (let y=0;y<=rows;y++) for(let x=0;x<=columns;x++) vertices.push([x/columns*1536,y/rows*1024]); + for (let y=0;y0) d[i+1]=Math.min(d[i+1],Math.round((Math.max(r,b)+.03)*255)); + } + ctx.putImageData(pixels,0,0); + return c; + } + function load(src) { + return new Promise((resolve,reject)=>{ + const image=new Image(); image.decoding='async'; + image.onload=()=>image.naturalWidth===1536 && image.naturalHeight===1024 ? resolve(image) : reject(new Error('Unexpected sprite size')); + image.onerror=()=>reject(new Error('Sprite unavailable')); + image.src=src; + }); + } + function resize() { + const rect=scene.getBoundingClientRect(); + if(!rect.width || !rect.height) return false; + const map=M.coverCentered(1672,941,rect.width,rect.height); + const width=box.width*map.scale,height=box.height*map.scale; + Object.assign(layer.style,{left:(map.left+box.x*map.scale)+'px',top:(map.top+box.y*map.scale)+'px',width:width+'px',height:height+'px'}); + const dpr=Math.min(devicePixelRatio||1,2,Math.sqrt(350000/(width*height))); + const w=Math.max(1,Math.round(width*dpr)),h=Math.max(1,Math.round(height*dpr)); + for(const c of [fallback,canvas]) { if(c.width!==w)c.width=w;if(c.height!==h)c.height=h; } + const ctx=fallback.getContext('2d'); + if(!ctx) throw new Error('No fallback canvas'); + ctx.setTransform(w/box.width,0,0,h/box.height,0,0); ctx.clearRect(0,0,box.width,box.height); + for(const a of animals) ctx.drawImage(a.image,a.x-box.x,a.y-box.y,1536*a.scale,1024*a.scale); + if(gl && !lost) gl.viewport(0,0,w,h); + dirty=false; return true; + } + function shader(type,source) { + const s=gl.createShader(type); if(!s)throw new Error('Shader unavailable'); + gl.shaderSource(s,source); gl.compileShader(s); + if(!gl.getShaderParameter(s,gl.COMPILE_STATUS)){gl.deleteShader(s);throw new Error('Shader failed');} + return s; + } + function build() { + if(gl && !lost) { + for(const a of animals)if(a.texture)gl.deleteTexture(a.texture); + if(alertTexture)gl.deleteTexture(alertTexture); + if(buffer)gl.deleteBuffer(buffer);if(index)gl.deleteBuffer(index);if(program)gl.deleteProgram(program); + } + gl=canvas.getContext('webgl',{alpha:true,premultipliedAlpha:true,antialias:true,depth:false,stencil:false,preserveDrawingBuffer:true,powerPreference:'low-power'}); + if(!gl)throw new Error('WebGL unavailable'); + const vs=shader(gl.VERTEX_SHADER,'attribute vec2 a_position;attribute vec2 a_uv;varying vec2 v_uv;void main(){v_uv=a_uv;gl_Position=vec4(a_position,0.0,1.0);}'); + const fs=shader(gl.FRAGMENT_SHADER,'precision mediump float;varying vec2 v_uv;uniform sampler2D u_image;uniform sampler2D u_alert;uniform float u_look;void main(){vec4 c=texture2D(u_image,v_uv);float head=smoothstep(940.0,990.0,v_uv.x*1536.0)*(1.0-smoothstep(290.0,370.0,v_uv.y*1024.0));c=mix(c,texture2D(u_alert,v_uv),head*u_look);gl_FragColor=vec4(c.rgb*vec3(0.83,0.88,0.80),c.a);}'); + program=gl.createProgram();if(!program)throw new Error('Program unavailable'); + gl.attachShader(program,vs);gl.attachShader(program,fs);gl.linkProgram(program);gl.deleteShader(vs);gl.deleteShader(fs); + if(!gl.getProgramParameter(program,gl.LINK_STATUS))throw new Error('Link failed'); + gl.useProgram(program);buffer=gl.createBuffer();index=gl.createBuffer(); + if(!buffer||!index)throw new Error('Buffer unavailable'); + gl.bindBuffer(gl.ARRAY_BUFFER,buffer);gl.bufferData(gl.ARRAY_BUFFER,mesh.byteLength,gl.DYNAMIC_DRAW); + const pos=gl.getAttribLocation(program,'a_position'),uv=gl.getAttribLocation(program,'a_uv'); + gl.enableVertexAttribArray(pos);gl.vertexAttribPointer(pos,2,gl.FLOAT,false,16,0); + gl.enableVertexAttribArray(uv);gl.vertexAttribPointer(uv,2,gl.FLOAT,false,16,8); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,index);gl.bufferData(gl.ELEMENT_ARRAY_BUFFER,new Uint16Array(indices),gl.STATIC_DRAW); + gl.activeTexture(gl.TEXTURE0);gl.uniform1i(gl.getUniformLocation(program,'u_image'),0); + uniforms={look:gl.getUniformLocation(program,'u_look')}; + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL,false);gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL,true); + for(const a of [...animals,{image:alertImage}]) { + a.texture=gl.createTexture();if(!a.texture)throw new Error('Texture unavailable'); + gl.bindTexture(gl.TEXTURE_2D,a.texture); + gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR); + gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,gl.RGBA,gl.UNSIGNED_BYTE,a.image); + if(a.image===alertImage)alertTexture=a.texture; + } + gl.activeTexture(gl.TEXTURE1);gl.bindTexture(gl.TEXTURE_2D,alertTexture);gl.uniform1i(gl.getUniformLocation(program,'u_alert'),1);gl.activeTexture(gl.TEXTURE0); + gl.enable(gl.BLEND);gl.blendFunc(gl.ONE,gl.ONE_MINUS_SRC_ALPHA);gl.disable(gl.DEPTH_TEST); + if(gl.getError()!==gl.NO_ERROR)throw new Error('GPU preparation failed'); + failed=false;lost=false;dirty=true; + } + function draw() { + if(dirty && !resize())return false; + gl.clearColor(0,0,0,0);gl.clear(gl.COLOR_BUFFER_BIT); + for(const a of animals) { + vertices.forEach(([x,y],i)=>{ + const p=M.transformPoint(x,y,a.pose,a.rig),n=i*4; + mesh[n]=((a.x+p[0]*a.scale-box.x)/box.width)*2-1; + mesh[n+1]=1-((a.y+p[1]*a.scale-box.y)/box.height)*2; + mesh[n+2]=x/1536;mesh[n+3]=y/1024; + }); + gl.bindBuffer(gl.ARRAY_BUFFER,buffer);gl.bufferSubData(gl.ARRAY_BUFFER,0,mesh); + gl.uniform1f(uniforms.look,M.smooth(a.look)); + gl.bindTexture(gl.TEXTURE_2D,a.texture);gl.drawElements(gl.TRIANGLES,indices.length,gl.UNSIGNED_SHORT,0); + } + if(gl.isContextLost())return false; + if(gl.getError()!==gl.NO_ERROR)throw new Error('GPU draw failed'); + fallback.hidden=true;canvas.hidden=false; + layer.dataset.frameCount=String(++frameCount); + layer.dataset.pose=animals.every(a=>a.pose===0&&a.look===1)?'alert':animals.every(a=>a.pose===1&&a.look===0)?'drinking':animals.every(a=>a.pose===0&&a.look===0)?'upright':'transitioning'; + layer.dataset.cycle=cycle; + layer.dataset.state='ready'; return true; + } + function tick(now) { + raf=0; + if(hidden()||failed||lost||!ready){previous=0;return;} + if(previous && now-previous<1000/30-.5){raf=requestAnimationFrame(tick);return;} + const dt=previous?Math.min((now-previous)/1000,.08):0;previous=now; + let changing=false; + for(const a of animals) { + const looking=target===1&&cycle==='raising'&&a.pose<.20; + const poseGoal=target===0||cycle==='raising'||(cycle==='lowering'&&a.look>.05)?0:1; + const lookGoal=looking?1:0; + let step=dt; + if(poseGoal===0&&a.raiseWait>0&&!reduced.matches){const wait=Math.min(step,a.raiseWait);a.raiseWait-=wait;step-=wait;} + // Raising is a quick alert reflex; lowering travels at 60% of that speed. + const duration=poseGoal>a.pose?a.duration/.6:a.duration; + a.pose=reduced.matches?target:M.advance(a.pose,poseGoal,step,duration); + a.look=reduced.matches?0:M.advance(a.look,lookGoal,step,.65); + const finalPose=target===0||cycle==='raising'?0:1; + const finalLook=target===1&&cycle==='raising'?1:0; + if(a.pose!==(reduced.matches?target:finalPose)||a.look!==(reduced.matches?0:finalLook))changing=true; + } + try { + if(!draw()){previous=0;return;} + if(changing){raf=requestAnimationFrame(tick);}else{ + previous=0; + if(cycle==='lowering')cycle='drink'; + if(target===1&&!reduced.matches){ + holdTimer=setTimeout(()=>{holdTimer=0;cycle=cycle==='drink'?'raising':'lowering';animals[1].raiseWait=cycle==='raising'?.5:0;reconcile();},cycle==='drink'?9000:2300); + } + } + } catch(error) { fallbackOnly('fallback'); } + } + function reconcile() { + const oldTarget=target; + cancel();target=M.stateTarget(water.dataset.state,target); + if(oldTarget!==0&&target===0)animals[1].raiseWait=animals[1].pose>0?.5:0; + if(target===0||reduced.matches)cycle='drink'; + if(!ready||hidden())return; + if(failed||lost){if(dirty)try{resize();}catch(error){}return;} + if(reduced.matches)for(const a of animals){a.pose=target;a.look=0;a.raiseWait=0;} + raf=requestAnimationFrame(tick); + } + const resized=()=>{dirty=true;reconcile();}; + new MutationObserver(reconcile).observe(water,{attributes:true,attributeFilter:['data-state']}); + new MutationObserver(reconcile).observe(scene,{attributes:true,attributeFilter:['hidden']}); + document.addEventListener('visibilitychange',reconcile); + window.addEventListener('resize',resized,{passive:true}); + if(window.ResizeObserver)new ResizeObserver(resized).observe(scene); + if(reduced.addEventListener)reduced.addEventListener('change',reconcile);else reduced.addListener(reconcile); + canvas.addEventListener('webglcontextlost',event=>{event.preventDefault();lost=true;fallbackOnly('context-lost');}); + canvas.addEventListener('webglcontextrestored',()=>{ + try{build();reconcile();}catch(error){fallbackOnly('fallback');} + }); + Promise.all([...animals.map(async a=>{a.image=keyedImage(await load(a.src));}),load('assets/garden-deer-alert-v1.webp').then(image=>{alertImage=keyedImage(image);})]).then(()=>{ + ready=true; + try { resize();build();reconcile(); }catch(error){fallbackOnly('fallback');} + }).catch(()=>{cancel();layer.hidden=true;layer.dataset.state='unavailable';}); +})(); diff --git a/assets/garden-deer-v1.md b/assets/garden-deer-v1.md new file mode 100644 index 0000000..b01d6db --- /dev/null +++ b/assets/garden-deer-v1.md @@ -0,0 +1,11 @@ +# Garden deer decoration + +Generated with OpenAI's built-in image tool for Delta Atlas on 2026-09-05, following the owner's mother-and-youngster direction. Three original 1536 × 1024 WebP sprites provide the mother's profile, the youngster's profile and an alert head variant. Sparse cream markings follow the upper spine. The three served files total 416,140 bytes. These are illustrated wildlife, not an anatomical simulation. + +The source sprites contain a green technical matte. The optional renderer removes it once with Canvas2D, then draws a small WebGL1 mesh. The existing water motion state controls the deer: playing permits drinking and occasional alert glances; pausing returns them to an upright profile. The mother raises her head half a second before the youngster. Raising takes about 0.313 / 0.363 seconds; lowering runs at 60% of that speed (about 0.521 / 0.604 seconds). The front-facing blend has a separate 0.65-second transition. + +The deer renderer targets at most 30 frames per second while moving, with two draw calls per frame, sleeps between poses, and cancels animation work when the scene or document is hidden. Reduced motion disables periodic alert loops. Canvas buffers are capped near 350,000 pixels and device-pixel ratio 2. Decoded textures and intermediate canvases consume more memory than the compressed asset transfer size; the source texture sets alone are roughly 18 MiB each on CPU and GPU. + +If WebGL fails, a composed upright Canvas2D fallback remains. If a required art file or compositor fails, the optional deer layer disappears. Decoration has no pointer targets or screen-reader content and does not modify tool input, output, storage, navigation or the water renderer. + +Geometry and lifecycle harnesses cover deterministic boundaries, motion state changes, scheduling, timing, reduced motion and simulated failures. Browser screenshots were reviewed at phone and desktop viewport sizes in Windows Chromium. These checks do not establish native Safari, macOS, Linux GPU or real-phone performance; those remain additional compatibility testing. diff --git a/assets/garden-deer-young-v1.webp b/assets/garden-deer-young-v1.webp new file mode 100644 index 0000000..e1197bc Binary files /dev/null and b/assets/garden-deer-young-v1.webp differ diff --git a/assets/garden-home-v1.css b/assets/garden-home-v1.css index e967314..66b4573 100644 --- a/assets/garden-home-v1.css +++ b/assets/garden-home-v1.css @@ -4,6 +4,9 @@ #garden-scene[hidden]{display:none;} #garden-still,#garden-water{position:absolute;inset:0;width:100%;height:100%;display:block;object-fit:cover;object-position:50% 50%;pointer-events:none;} .garden-atmosphere{position:absolute;inset:0;pointer-events:none;background:linear-gradient(90deg,rgba(5,21,14,.18),transparent 62%),linear-gradient(0deg,rgba(5,20,14,.32),transparent 48%);} +#garden-deer{position:absolute;pointer-events:none;overflow:hidden;} +#garden-deer canvas{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;} +#garden-deer[hidden],#garden-deer canvas[hidden]{display:none;} #home{z-index:1;background:transparent;} #frame{position:relative;z-index:2;} /* Preserve the home-page overflow boundary that prevents decorative blank scrolling. */ @@ -49,7 +52,8 @@ /* A smaller left-aligned introduction leaves more of the garden visible. Keep the task cards and their scope note together below the open scenery. */ @media(min-width:721px){ - .hero{width:min(390px,100%);min-height:0;margin-top:24px;margin-bottom:112px;padding:20px 24px;} + /* Keep the lower-bank wildlife above the tools as the wide scene scales. */ + .hero{width:min(390px,100%);min-height:0;margin-top:24px;margin-bottom:max(112px,calc(42vw - 233px));padding:20px 24px;} .hero .logo{font-size:14px;} .hero .headline{font-size:34px;line-height:1.12;margin:13px 0;} .hero .tagline{font-size:14px;line-height:1.6;} diff --git a/governance/harnesses/verify-atlas-runtime.js b/governance/harnesses/verify-atlas-runtime.js index 40bb68b..4e61b14 100644 --- a/governance/harnesses/verify-atlas-runtime.js +++ b/governance/harnesses/verify-atlas-runtime.js @@ -7,7 +7,7 @@ const fail=m=>{throw new Error(m);}; const contract=JSON.parse(read('governance/contracts/atlas-runtime-contract.v1.json')); if(contract.type!=='delta-atlas-runtime-contract'||contract.version!==1) fail('contract identity'); const sw=read('sw.js'); -if(!sw.includes("const CACHE='aaig-v96'")) fail('cache version'); +if(!sw.includes("const CACHE='aaig-v97'")) fail('cache version'); if(/allSettled/.test(sw)) fail('install must not accept partial cache'); if(!/Promise\.all\(CORE\.map\(u=>c\.add\(u\)\)\)/.test(sw)) fail('install is not fail-closed'); const coreMatch=sw.match(/const CORE=\[([\s\S]*?)\];/); if(!coreMatch) fail('CORE not found'); diff --git a/governance/harnesses/verify-garden-deer-runtime.js b/governance/harnesses/verify-garden-deer-runtime.js new file mode 100644 index 0000000..c778f1b --- /dev/null +++ b/governance/harnesses/verify-garden-deer-runtime.js @@ -0,0 +1,499 @@ +#!/usr/bin/env node +'use strict'; + +// Executes the actual browser runtime with deterministic DOM/RAF/timer/Image/GL mocks. +// These checks establish scheduling, state, and fallback decisions only. They do +// not decode the artwork, render GPU pixels, or establish browser/OS compatibility. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const root = path.resolve(__dirname, '../..'); +const source = fs.readFileSync(path.join(root, 'assets/garden-deer-v1.js'), 'utf8'); +const model = require(path.join(root, 'assets/garden-deer-pose-v1.js')); +let passed = 0, failed = 0; +async function check(name, run) { + try { await run(); passed++; console.log('PASS ' + name); } + catch (error) { failed++; console.error('FAIL ' + name + ': ' + error.message); } +} + +function fixture(options = {}) { + const observers = [], pendingObservers = new Set(), resizeObservers = []; + const queue = new Map(), timers = new Map(), images = [], renders = [], requests = []; + let clock = 100, nextRaf = 1, requestsMade = 0, maxPending = 0, glLost = false; + let nextTimer = 1, maxTimers = 0; + let drawError = false, poseSamples = [], lookSamples = [], draws = 0, preparations = 0; + const eventTarget = () => ({ + listeners: new Map(), + addEventListener(type, fn) { + if (!this.listeners.has(type)) this.listeners.set(type, []); + this.listeners.get(type).push(fn); + }, + dispatch(type, event = {}) { + for (const fn of this.listeners.get(type) || []) fn(event); + flushObservers(); + }, + }); + function changed(target, attributeName) { + for (const observer of observers) { + if (observer.target === target && observer.options.attributes && + (!observer.options.attributeFilter || observer.options.attributeFilter.includes(attributeName))) { + pendingObservers.add(observer); + } + } + } + function flushObservers() { + while (pendingObservers.size) { + const pending = [...pendingObservers]; pendingObservers.clear(); + for (const observer of pending) { observer.calls++; observer.fn([]); } + } + } + function element(id = '') { + const e = Object.assign(eventTarget(), { id, style: {}, children: [], attributes: {} }); + let isHidden = false; + Object.defineProperty(e, 'hidden', { + get: () => isHidden, + set: value => { isHidden = Boolean(value); changed(e, 'hidden'); }, + }); + e.dataset = new Proxy({}, { set(target, key, value) { + target[key] = String(value); + changed(e, 'data-' + key.replace(/[A-Z]/g, c => '-' + c.toLowerCase())); + return true; + } }); + e.setAttribute = (name, value) => { e.attributes[name] = String(value); changed(e, name); }; + e.append = (...children) => e.children.push(...children); + return e; + } + const gl = {}; + const constants = ['VERTEX_SHADER', 'FRAGMENT_SHADER', 'COMPILE_STATUS', 'LINK_STATUS', + 'ARRAY_BUFFER', 'ELEMENT_ARRAY_BUFFER', 'DYNAMIC_DRAW', 'STATIC_DRAW', 'FLOAT', + 'TEXTURE0', 'TEXTURE1', 'TEXTURE_2D', 'UNPACK_FLIP_Y_WEBGL', 'UNPACK_PREMULTIPLY_ALPHA_WEBGL', + 'TEXTURE_WRAP_S', 'TEXTURE_WRAP_T', 'CLAMP_TO_EDGE', 'TEXTURE_MIN_FILTER', + 'TEXTURE_MAG_FILTER', 'LINEAR', 'RGBA', 'UNSIGNED_BYTE', 'BLEND', 'ONE', + 'ONE_MINUS_SRC_ALPHA', 'DEPTH_TEST', 'COLOR_BUFFER_BIT', 'TRIANGLES', 'UNSIGNED_SHORT']; + constants.forEach((name, i) => { gl[name] = i + 1; }); + gl.NO_ERROR = 0; + for (const name of ['shaderSource', 'compileShader', 'deleteShader', 'attachShader', + 'linkProgram', 'useProgram', 'bindBuffer', 'bufferData', 'enableVertexAttribArray', + 'vertexAttribPointer', 'activeTexture', 'uniform1i', 'pixelStorei', 'bindTexture', + 'texParameteri', 'enable', 'blendFunc', 'disable', 'clearColor', 'deleteTexture', + 'deleteBuffer', 'deleteProgram', 'viewport', 'bufferSubData']) gl[name] = () => {}; + for (const name of ['createShader', 'createProgram', 'createBuffer', 'createTexture']) { + gl[name] = () => options.allocationFailure === name ? null : {}; + } + gl.getShaderParameter = () => !options.shaderFailure; + gl.getProgramParameter = () => !options.linkFailure; + gl.getAttribLocation = (_program, name) => name === 'a_position' ? 0 : 1; + gl.getUniformLocation = () => ({}); + gl.texImage2D = () => { preparations++; }; + gl.getError = () => options.textureFailure || drawError ? 1282 : 0; + gl.isContextLost = () => glLost; + gl.clear = () => { poseSamples = []; lookSamples = []; }; + gl.uniform1f = (_location, value) => { lookSamples.push(value); }; + gl.drawElements = () => { draws++; }; + function canvas() { + const e = element(); e.width = 300; e.height = 150; + const twoD = { + drawImage() {}, setTransform() {}, clearRect() {}, putImageData() {}, + // Representative chroma/opaque samples; no full-image pixel allocation. + getImageData: () => ({ data: new Uint8ClampedArray([0, 255, 0, 255, 110, 70, 35, 255]) }), + }; + e.getContext = kind => kind === '2d' ? (options.no2D ? null : twoD) + : (kind === 'webgl' && !options.noGL ? gl : null); + return e; + } + const layer = element('garden-deer'), scene = element('garden-scene'), water = element('garden-water'); + let rect = options.rect || { width: 1672, height: 941 }; + scene.getBoundingClientRect = () => rect; + scene.hidden = Boolean(options.sceneHidden); + water.dataset.state = options.state || 'paused'; + const document = Object.assign(eventTarget(), { + hidden: Boolean(options.documentHidden), + getElementById: id => ({ 'garden-deer': layer, 'garden-scene': scene, 'garden-water': water })[id] || null, + createElement: name => { assert.equal(name, 'canvas'); return canvas(); }, + }); + const reduced = Object.assign(eventTarget(), { matches: Boolean(options.reduced) }); + reduced.addListener = fn => reduced.addEventListener('change', fn); + const win = eventTarget(); + const instrumentedModel = { ...model, transformPoint(x, y, pose, rig) { + if (x === 0 && y === 0) poseSamples.push(pose); + return model.transformPoint(x, y, pose, rig); + } }; + class MockMutationObserver { + constructor(fn) { this.fn = fn; this.calls = 0; } + observe(target, config) { this.target = target; this.options = config; observers.push(this); } + } + class MockResizeObserver { + constructor(fn) { this.fn = fn; resizeObservers.push(this); } + observe(target) { this.target = target; } + } + class MockImage { + constructor() { + images.push(this); this.naturalWidth = options.imageWidth ?? 1536; + this.naturalHeight = options.imageHeight ?? 1024; + } + set src(value) { this.url = value; requests.push(value); } + get src() { return this.url; } + } + const sandbox = { + window: win, document, Image: MockImage, MutationObserver: MockMutationObserver, + ResizeObserver: MockResizeObserver, matchMedia: () => reduced, + devicePixelRatio: options.dpr || 1, console, + requestAnimationFrame(fn) { + const id = nextRaf++; queue.set(id, fn); requestsMade++; + maxPending = Math.max(maxPending, queue.size); return id; + }, + cancelAnimationFrame: id => queue.delete(id), + setTimeout(fn, delay) { + assert.equal(typeof fn, 'function'); assert.ok(Number.isFinite(delay) && delay >= 0); + const id = nextTimer++; timers.set(id, { fn, due: clock + delay }); + maxTimers = Math.max(maxTimers, timers.size); return id; + }, + clearTimeout: id => timers.delete(id), + fetch() { throw new Error('Unexpected network API'); }, + localStorage: new Proxy({}, { get() { throw new Error('Unexpected persistent storage'); } }), + sessionStorage: new Proxy({}, { get() { throw new Error('Unexpected persistent storage'); } }), + }; + Object.assign(win, { GardenDeerPose: instrumentedModel, ResizeObserver: MockResizeObserver }); + const context = vm.createContext(sandbox); + vm.runInContext(source, context, { filename: 'garden-deer-v1.js' }); + async function load(failIndex = -1) { + images.forEach((image, i) => i === failIndex ? image.onerror() : image.onload()); + for (let i = 0; i < 6; i++) await Promise.resolve(); + flushObservers(); + } + function advanceTime(ms) { + assert.ok(Number.isFinite(ms) && ms >= 0); + const end = clock + ms; + for (let count = 0; ; count++) { + assert.ok(count < 1000, 'timer loop did not yield to rendering'); + const next = [...timers.entries()].sort((a, b) => a[1].due - b[1].due)[0]; + if (!next || next[1].due > end) break; + clock = next[1].due; timers.delete(next[0]); next[1].fn(); flushObservers(); + } + clock = end; + } + function step(ms = 16) { + advanceTime(ms); + const pending = [...queue.values()]; queue.clear(); + for (const fn of pending) { + const before = draws; fn(clock); + if (draws !== before) renders.push({ poses: [...poseSamples], looks: [...lookSamples], + frames: layer.dataset.frameCount, time: clock }); + } + flushObservers(); + } + function settle(limit = 400) { + for (let i = 0; queue.size && i < limit; i++) step(); + assert.equal(queue.size, 0, 'animation did not settle within bounded frames'); + } + return { + layer, scene, water, document, reduced, observers, requests, renders, load, step, settle, advanceTime, + get pending() { return queue.size; }, get requestsMade() { return requestsMade; }, + get maxPending() { return maxPending; }, get draws() { return draws; }, + get pendingTimers() { return timers.size; }, get maxTimers() { return maxTimers; }, + get nextTimerDelay() { return timers.size ? Math.min(...[...timers.values()].map(t => t.due - clock)) : null; }, + get preparations() { return preparations; }, + get fallback() { return layer.children[0]; }, get canvas() { return layer.children[1]; }, + get lastPoses() { return renders.at(-1)?.poses || []; }, + get lastLooks() { return renders.at(-1)?.looks || []; }, + waterState(value) { water.dataset.state = value; flushObservers(); }, + waterFrame(value) { water.dataset.frameCount = value; flushObservers(); }, + sceneHidden(value) { scene.hidden = value; flushObservers(); }, + documentHidden(value) { document.hidden = value; document.dispatch('visibilitychange'); }, + reducedMotion(value) { reduced.matches = value; reduced.dispatch('change'); }, + resize(value) { rect = value; for (const observer of resizeObservers) observer.fn(); flushObservers(); }, + failDraw() { drawError = true; }, + lose() { + glLost = true; let prevented = false; + this.canvas.dispatch('webglcontextlost', { preventDefault() { prevented = true; } }); + assert.equal(prevented, true, 'context loss must permit restoration'); + }, + restore() { glLost = false; this.canvas.dispatch('webglcontextrestored'); }, + rerun() { vm.runInContext(source, context); }, + }; +} + +function upright(f) { + assert.equal(f.layer.dataset.pose, 'upright'); + assert.ok(f.lastPoses.every(value => value === 0)); + assert.ok(f.lastLooks.every(value => value === 0)); +} +function drinking(f) { + assert.equal(f.layer.dataset.pose, 'drinking'); + assert.ok(f.lastPoses.length > 0 && f.lastPoses.every(value => value === 1)); + assert.ok(f.lastLooks.every(value => value === 0)); +} +function fallback(f) { + assert.equal(f.canvas.hidden, true); assert.equal(f.fallback.hidden, false); + assert.equal(f.layer.dataset.pose, 'upright'); assert.equal(f.pending, 0); + assert.equal(f.pendingTimers, 0); +} + +(async () => { + await check('initial running state reaches drinking and rests with one timer and no RAF', async () => { + const f = fixture({ state: 'running' }); await f.load(); f.settle(); drinking(f); + assert.equal(f.pendingTimers, 1); assert.equal(f.nextTimerDelay, 9000); + const count = f.draws; f.advanceTime(8999); assert.equal(f.draws, count); assert.equal(f.pending, 0); + assert.equal(f.maxPending, 1); assert.equal(f.maxTimers, 1); + assert.equal(f.canvas.hidden, false); assert.equal(f.fallback.hidden, true); + }); + await check('periodic cycle drinks for nine seconds, looks forward, holds, then lowers', async () => { + const f = fixture({ state: 'running' }); await f.load(); f.settle(); drinking(f); + const count = f.draws; + f.advanceTime(8999); assert.equal(f.draws, count); assert.equal(f.pending, 0); + f.advanceTime(1); assert.equal(f.pending, 1); assert.equal(f.pendingTimers, 0); + f.settle(); + assert.equal(f.layer.dataset.pose, 'alert'); + assert.ok(f.lastPoses.every(value => value === 0)); + assert.ok(f.lastLooks.length > 0 && f.lastLooks.every(value => value === 1)); + assert.equal(f.pendingTimers, 1); assert.equal(f.nextTimerDelay, 2300); + const alertDraws = f.draws; + f.advanceTime(2299); assert.equal(f.draws, alertDraws); assert.equal(f.pending, 0); + f.advanceTime(1); assert.equal(f.pending, 1); assert.equal(f.pendingTimers, 0); + f.settle(); drinking(f); + assert.equal(f.pendingTimers, 1); assert.equal(f.nextTimerDelay, 9000); + assert.equal(f.maxPending, 1); assert.equal(f.maxTimers, 1); + assert.ok(f.renders.some(frame => frame.looks.some(value => value > 0 && value < 1)), + 'front-facing blend must transition through intermediate values'); + }); + await check('mother raises first; young waits half a second on periodic raising and Pause', async () => { + for (const trigger of ['periodic', 'pause']) { + const f = fixture({ state: 'running' }); await f.load(); f.settle(); drinking(f); + if (trigger === 'periodic') f.advanceTime(9000); else f.waterState('paused'); + f.step(0); // Establish the first visible transition timestamp. + assert.deepEqual(f.lastPoses, [1, 1]); + for (let elapsed = 40; elapsed <= 480; elapsed += 40) { + f.step(40); + assert.ok(f.lastPoses[0] < 1, trigger + ': mother did not begin raising'); + assert.equal(f.lastPoses[1], 1, trigger + ': young moved before the half-second delay'); + } + f.step(20); // At 500 ms the frame cap may retain the 480 ms render. + assert.equal(f.lastPoses[1], 1, trigger + ': young moved before delay was consumed'); + f.step(20); // First rendered frame after the half-second boundary. + assert.ok(f.lastPoses[1] < 1, trigger + ': young did not move after the delay'); + assert.ok(f.lastPoses[0] < f.lastPoses[1], trigger + ': mother lost its raising lead'); + f.settle(); + assert.equal(f.layer.dataset.pose, trigger === 'periodic' ? 'alert' : 'upright'); + assert.equal(f.maxPending, 1); assert.equal(f.maxTimers, 1); + } + }); + await check('Pause from the alert hold returns to upright profile and cancels future cycles', async () => { + const f = fixture({ state: 'running' }); await f.load(); f.settle(); + f.advanceTime(9000); f.settle(); assert.equal(f.layer.dataset.pose, 'alert'); + f.waterState('paused'); assert.equal(f.pendingTimers, 0); f.settle(); upright(f); + const draws = f.draws; f.advanceTime(30000); + assert.equal(f.draws, draws); assert.equal(f.pending, 0); assert.equal(f.pendingTimers, 0); + }); + for (const kind of ['document', 'scene']) { + for (const phase of ['drink', 'alert']) { + await check(kind + ' hiding cancels the ' + phase + ' hold timer until resumed', async () => { + const f = fixture({ state: 'running' }); await f.load(); f.settle(); + if (phase === 'alert') { f.advanceTime(9000); f.settle(); } + const pose = f.lastPoses, looks = f.lastLooks, count = f.draws; + const hide = value => kind === 'document' ? f.documentHidden(value) : f.sceneHidden(value); + hide(true); f.waterState('hidden'); + assert.equal(f.pending, 0); assert.equal(f.pendingTimers, 0); + f.advanceTime(30000); assert.equal(f.draws, count); + hide(false); f.waterState('starting'); f.waterState('running'); f.settle(); + assert.deepEqual(f.lastPoses, pose); assert.deepEqual(f.lastLooks, looks); + assert.equal(f.pendingTimers, 1); assert.equal(f.maxTimers, 1); assert.equal(f.maxPending, 1); + }); + } + } + await check('reduced motion cancels alert hold and disables the periodic loop during explicit Play', async () => { + const f = fixture({ state: 'running' }); await f.load(); f.settle(); + f.advanceTime(9000); f.settle(); assert.equal(f.layer.dataset.pose, 'alert'); + // Explicit Play is supported under reduced motion; it may select drinking, + // but neither pose interpolation nor periodic timers may remain active. + f.reducedMotion(true); assert.equal(f.pendingTimers, 0); f.step(); drinking(f); + assert.equal(f.pending, 0); assert.equal(f.pendingTimers, 0); + const count = f.draws; f.advanceTime(30000); assert.equal(f.draws, count); + }); + await check('context loss clears the resting timer before recovery', async () => { + const f = fixture({ state: 'running' }); await f.load(); f.settle(); + assert.equal(f.pendingTimers, 1); f.lose(); fallback(f); + f.advanceTime(30000); assert.equal(f.pending, 0); assert.equal(f.pendingTimers, 0); + f.restore(); f.settle(); drinking(f); assert.equal(f.pendingTimers, 1); + }); + await check('active rendering is capped at thirty draws per second', async () => { + const f = fixture({ state: 'running' }); await f.load(); + let callbacks = 0; + // Drive a 120 Hz display only while the real, unmodified transition runs. + // Its duration may change; the frame-spacing requirement does not. + while (f.pending && callbacks < 240) { f.step(1000 / 120); callbacks++; } + drinking(f); assert.equal(f.pending, 0); + assert.ok(f.renders.length > 2 && callbacks > f.renders.length, + 'test must observe intermediate renders and throttled RAF callbacks'); + assert.ok(f.renders.some(frame => frame.poses.some(value => value > 0 && value < 1))); + for (let i = 1; i < f.renders.length; i++) { + assert.ok(f.renders[i].time - f.renders[i - 1].time >= 1000 / 30 - .5, + 'deer drew faster than the declared thirty-frame cap'); + } + }); + await check('observed lowering speed is sixty percent of the faster raising speed', async () => { + const f = fixture(); await f.load(); f.settle(); + const lowerStart = f.renders.length; + f.waterState('running'); f.settle(); drinking(f); + const lowering = f.renders.slice(lowerStart), raiseStart = f.renders.length; + f.waterState('paused'); f.settle(); upright(f); + const raising = f.renders.slice(raiseStart); + function observedSpeed(frames, animal, direction) { + const samples = []; + for (let i = 1; i < frames.length; i++) { + const from = frames[i - 1].poses[animal], to = frames[i].poses[animal]; + // Exclude waits and endpoint clamping. Infer full-travel speed from + // actual pose changes passed to the mesh, without reading durations. + if (from > 0 && from < 1 && to > 0 && to < 1 && (to - from) * direction > 0) { + samples.push(Math.abs(to - from) * 1000 / (frames[i].time - frames[i - 1].time)); + } + } + assert.ok(samples.length >= 2, 'insufficient moving frames to measure travel speed'); + return samples.reduce((sum, value) => sum + value, 0) / samples.length; + } + for (const [animal, originalRiseSeconds] of [2.5, 2.9].entries()) { + const lowerSpeed = observedSpeed(lowering, animal, 1); + const raiseSpeed = observedSpeed(raising, animal, -1); + assert.ok(Math.abs(lowerSpeed / raiseSpeed - .6) < 1e-8, + 'lowering did not travel at sixty percent of raising speed'); + assert.ok(Math.abs(1 / raiseSpeed - originalRiseSeconds / 8) < 1e-8, + 'observed raising travel did not use the requested eightfold speed'); + } + }); + await check('Pause raises the deer and settles upright', async () => { + const f = fixture({ state: 'running' }); await f.load(); f.settle(); + f.waterState('paused'); assert.equal(f.pendingTimers, 0); + f.settle(); upright(f); assert.equal(f.pending, 0); assert.equal(f.pendingTimers, 0); + }); + await check('late initialization catches paused state with one static draw', async () => { + const f = fixture(); await f.load(); f.settle(); upright(f); + assert.equal(f.renders.length, 1); + }); + await check('rapid reversals continue from the current pose with one pending frame', async () => { + const f = fixture({ state: 'running' }); await f.load(); + for (let i = 0; i < 24; i++) f.step(); + const before = f.lastPoses; + assert.ok(before.every(value => value > 0 && value < 1)); + f.waterState('paused'); f.step(); assert.deepEqual(f.lastPoses, before); + f.step(40); const descending = f.lastPoses; + assert.ok(descending[0] < before[0]); + assert.equal(descending[1], before[1], 'young should retain its pose during the raising delay'); + f.waterState('running'); f.step(); assert.deepEqual(f.lastPoses, descending); + f.settle(); drinking(f); assert.equal(f.maxPending, 1); + }); + await check('water observer ignores the continuously changing frame counter', async () => { + const f = fixture(); await f.load(); f.settle(); + const observer = f.observers.find(item => item.target === f.water); + assert.deepEqual([...observer.options.attributeFilter], ['data-state']); + const calls = observer.calls, requests = f.requestsMade; + for (let i = 0; i < 100; i++) f.waterFrame(i); + assert.equal(observer.calls, calls); assert.equal(f.requestsMade, requests); + }); + for (const kind of ['document', 'scene']) { + await check(kind + ' hiding cancels an active tween and resumes its pose', async () => { + const f = fixture({ state: 'running' }); await f.load(); + for (let i = 0; i < 20; i++) f.step(); + const pose = f.lastPoses, draws = f.draws; + const hide = value => kind === 'document' ? f.documentHidden(value) : f.sceneHidden(value); + hide(true); assert.equal(f.pending, 0); f.step(10000); assert.equal(f.draws, draws); + f.waterState('hidden'); hide(false); f.waterState('starting'); f.step(); + assert.deepEqual(f.lastPoses, pose); f.waterState('running'); f.settle(); drinking(f); + assert.equal(f.maxPending, 1); + }); + } + await check('initially hidden scene does not draw before becoming visible', async () => { + const f = fixture({ state: 'running', sceneHidden: true }); await f.load(); + assert.equal(f.draws, 0); assert.equal(f.pending, 0); + f.sceneHidden(false); f.settle(); drinking(f); + }); + await check('reduced motion snaps to requested endpoints without a tween', async () => { + const f = fixture({ state: 'reduced-motion', reduced: true }); await f.load(); f.settle(); upright(f); + const count = f.renders.length; f.waterState('running'); f.step(); drinking(f); + assert.equal(f.renders.length, count + 1); assert.equal(f.pending, 0); + f.waterState('reduced-motion'); f.step(); upright(f); assert.equal(f.pending, 0); + }); + await check('a reduced-motion change cancels a live tween and does not resume on its own', async () => { + const f = fixture({ state: 'running' }); await f.load(); + for (let i = 0; i < 20; i++) f.step(); + // The existing water preference handler pauses first; the deer reads that state. + f.waterState('reduced-motion'); f.reducedMotion(true); f.step(); upright(f); + assert.equal(f.pending, 0); f.reducedMotion(false); f.settle(); upright(f); + }); + await check('settled poses redraw on resize within the sprite pixel budget', async () => { + const f = fixture({ dpr: 3 }); await f.load(); f.settle(); + const count = f.renders.length; f.resize({ width: 3840, height: 2160 }); f.settle(); + assert.equal(f.renders.length, count + 1); upright(f); + assert.ok(f.canvas.width * f.canvas.height <= 352000, 'rounded buffer exceeds bounded pixel budget'); + assert.equal(f.canvas.width, f.fallback.width); assert.equal(f.canvas.height, f.fallback.height); + }); + await check('zero-size layout waits without spinning and recovers on resize', async () => { + const f = fixture({ state: 'running', rect: { width: 0, height: 0 } }); await f.load(); f.step(); + assert.equal(f.pending, 0); assert.equal(f.draws, 0); + f.resize({ width: 390, height: 220 }); f.settle(); drinking(f); + }); + await check('image failure hides only the optional deer layer', async () => { + const f = fixture({ state: 'running' }); await f.load(0); + assert.equal(f.layer.hidden, true); assert.equal(f.layer.dataset.state, 'unavailable'); + assert.equal(f.pending, 0); assert.equal(f.water.dataset.state, 'running'); assert.equal(f.scene.hidden, false); + }); + await check('failure of the alert artwork also hides the optional layer without timers', async () => { + const f = fixture({ state: 'running' }); await f.load(2); + assert.equal(f.layer.hidden, true); assert.equal(f.layer.dataset.state, 'unavailable'); + assert.equal(f.pending, 0); assert.equal(f.pendingTimers, 0); + assert.equal(f.water.dataset.state, 'running'); + }); + await check('unexpected image dimensions are rejected before GPU preparation', async () => { + const f = fixture({ state: 'running', imageWidth: 0 }); await f.load(); + assert.equal(f.layer.hidden, true); assert.equal(f.layer.dataset.state, 'unavailable'); + assert.equal(f.preparations, 0); assert.equal(f.pending, 0); + }); + await check('unavailable local compositor isolates failure to the optional layer', async () => { + const f = fixture({ state: 'running', no2D: true }); await f.load(); + assert.equal(f.layer.hidden, true); assert.equal(f.layer.dataset.state, 'unavailable'); + assert.equal(f.pending, 0); assert.equal(f.water.dataset.state, 'running'); + }); + await check('unavailable WebGL keeps the composed upright fallback', async () => { + const f = fixture({ state: 'running', noGL: true }); await f.load(); fallback(f); + f.waterState('paused'); f.resize({ width: 390, height: 220 }); fallback(f); + assert.equal(f.draws, 0); assert.equal(f.layer.hidden, false); + }); + for (const fault of [{ shaderFailure: true }, { linkFailure: true }, { textureFailure: true }, + { allocationFailure: 'createBuffer' }, { allocationFailure: 'createTexture' }]) { + await check('GPU preparation failure uses fallback: ' + JSON.stringify(fault), async () => { + const f = fixture({ state: 'running', ...fault }); await f.load(); fallback(f); + assert.equal(f.water.dataset.state, 'running'); + }); + } + await check('a draw error stops scheduling and leaves the upright fallback', async () => { + const f = fixture({ state: 'running' }); await f.load(); f.step(); f.failDraw(); f.step(40); fallback(f); + assert.equal(f.layer.dataset.state, 'fallback'); + }); + await check('context loss cancels the tween and restore follows the latest water target', async () => { + const f = fixture({ state: 'running' }); await f.load(); f.settle(); f.lose(); fallback(f); + assert.equal(f.layer.dataset.state, 'context-lost'); + const preparations = f.preparations; f.waterState('paused'); f.restore(); f.settle(); upright(f); + assert.ok(f.preparations > preparations, 'restoration must upload new context textures'); + assert.equal(f.maxPending, 1); + }); + await check('restoration starts from the visible upright fallback without a pose flash', async () => { + const f = fixture({ state: 'running' }); await f.load(); f.settle(); f.lose(); fallback(f); + f.waterState('paused'); f.restore(); f.step(); + assert.ok(f.lastPoses.length > 0 && f.lastPoses.every(value => value === 0), + 'first restored frame reused the pre-loss drinking pose although fallback was upright'); + }); + await check('context restoration while hidden schedules nothing until visibility returns', async () => { + const f = fixture({ state: 'running' }); await f.load(); f.step(); f.lose(); + f.sceneHidden(true); f.restore(); assert.equal(f.pending, 0); + f.sceneHidden(false); f.settle(); drinking(f); + }); + await check('initialization is idempotent and requests only the three local art files', async () => { + const f = fixture(); await f.load(); f.settle(); + const children = f.layer.children.length, observers = f.observers.length; + f.rerun(); assert.equal(f.layer.children.length, children); assert.equal(f.observers.length, observers); + assert.equal(f.requests.length, 3); + assert.ok(f.requests.every(url => /^assets\/garden-deer-[a-z]+-v1\.webp$/.test(url))); + assert.equal(f.water.dataset.state, 'paused'); + }); + console.log(`Garden deer runtime: ${passed} passed, ${failed} failed (synthetic lifecycle; no GPU/browser claim).`); + process.exitCode = failed ? 1 : 0; +})(); diff --git a/governance/harnesses/verify-garden-deer.js b/governance/harnesses/verify-garden-deer.js new file mode 100644 index 0000000..66c9cc3 --- /dev/null +++ b/governance/harnesses/verify-garden-deer.js @@ -0,0 +1,167 @@ +#!/usr/bin/env node +'use strict'; +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const modulePath = path.join(__dirname, '../../assets/garden-deer-pose-v1.js'); +const pose = require(modulePath); +const rigs = { + mother: { root: [900, 550], head: [1070, 235], neckAngle: 1.95, headAngle: -0.85 }, + fawn: { root: [900, 550], head: [1070, 235], neckAngle: 1.95, headAngle: -0.85 } +}; +let passed = 0; +function test(name, run) { run(); passed++; console.log('PASS ' + name); } +function near(actual, expected, tolerance = 1e-9) { assert.ok(Math.abs(actual - expected) <= tolerance, `${actual} != ${expected}`); } +function nearPoint(actual, expected, tolerance = 1e-9) { near(actual[0], expected[0], tolerance); near(actual[1], expected[1], tolerance); } + +test('browser UMD export works without DOM or CommonJS', () => { + const sandbox = {}; + vm.runInNewContext(fs.readFileSync(modulePath, 'utf8'), sandbox); + assert.equal(typeof sandbox.GardenDeerPose.transformPoint, 'function'); + assert.deepEqual(Object.keys(sandbox.GardenDeerPose).sort(), Object.keys(pose).sort()); +}); +test('running lowers; paused, reduced motion, and failures raise the head', () => { + assert.equal(pose.stateTarget('running', 0), 1); + for (const state of ['paused', 'reduced-motion', 'error', 'unavailable', 'context-lost']) assert.equal(pose.stateTarget(state, 1), 0); +}); +test('transient and hidden renderer states hold the existing target', () => { + for (const state of ['loading', 'starting', 'waiting', 'hidden', 'unknown']) { + assert.equal(pose.stateTarget(state, 1), 1); + assert.equal(pose.stateTarget(state, 0), 0); + assert.equal(pose.stateTarget(state, 0.4), 0.4); + } +}); +test('linear phase and render easing have bounded exact endpoints', () => { + for (const t of [0, 1]) near(pose.smooth(t), t); + near(pose.smooth(-1), 0); near(pose.smooth(2), 1); + near(pose.advance(0, 1, 0.25, 2), 0.125); + near(pose.advance(0.8, 1, 10, 2), 1); + near(pose.advance(0.2, 0, 10, 2), 0); + near(pose.advance(0.5, 1, -1, 2), 0.5); + near(pose.advance(0.5, 1, 0.1, 0), 1); +}); +test('advance is independent of frame subdivision', () => { + let current = 0; + for (let i = 0; i < 30; i++) current = pose.advance(current, 1, 1 / 30, 2); + near(current, pose.advance(0, 1, 1, 2)); +}); + +for (const [name, rig] of Object.entries(rigs)) { + test(name + ': upright endpoint preserves every sampled bitmap coordinate exactly', () => { + for (let y = 0; y <= 1024; y += 64) for (let x = 0; x <= 1536; x += 64) assert.deepEqual(pose.transformPoint(x, y, 0, rig), [x, y]); + }); + test(name + ': hooves, lower legs, rear torso, and shoulder root stay planted', () => { + const points = [[500, 900], [750, 970], [1020, 940], [rig.root[0] - 150, 300], + [rig.root[0] - 75, rig.root[1] - 60], [rig.root[0], rig.root[1]], [rig.root[0] + 80, rig.root[1] + 120]]; + for (const amount of [0, 0.2, 0.5, 0.8, 1]) for (const point of points) assert.deepEqual(pose.transformPoint(...point, amount, rig), point); + }); + test(name + ': head center lowers monotonically to the intended neck arc', () => { + let lastY = rig.head[1]; + for (let i = 0; i <= 100; i++) { + const point = pose.transformPoint(...rig.head, i / 100, rig); + assert.ok(Number.isFinite(point[0]) && Number.isFinite(point[1])); + assert.ok(point[1] >= lastY - 1e-9, 'head rose during lowering'); + lastY = point[1]; + } + const dx = rig.head[0] - rig.root[0], dy = rig.head[1] - rig.root[1]; + const expected = [rig.root[0] + dx * Math.cos(rig.neckAngle) - dy * Math.sin(rig.neckAngle), + rig.root[1] + dx * Math.sin(rig.neckAngle) + dy * Math.cos(rig.neckAngle)]; + nearPoint(pose.transformPoint(...rig.head, 1, rig), expected); + assert.ok(lastY > rig.root[1] + 200, 'head did not reach the lower feeding region'); + }); + test(name + ': skull counterrotation preserves local shape and limits total rotation', () => { + const center = pose.transformPoint(...rig.head, 1, rig); + const right = pose.transformPoint(rig.head[0] + 20, rig.head[1], 1, rig); + const up = pose.transformPoint(rig.head[0], rig.head[1] - 20, 1, rig); + near(Math.hypot(right[0] - center[0], right[1] - center[1]), 20); + near(Math.hypot(up[0] - center[0], up[1] - center[1]), 20); + near((right[0] - center[0]) * (up[0] - center[0]) + (right[1] - center[1]) * (up[1] - center[1]), 0, 1e-7); + const angle = Math.atan2(right[1] - center[1], right[0] - center[0]); + near(angle, rig.neckAngle + rig.headAngle); + assert.ok(Math.abs(angle) < Math.PI / 2, 'skull turned more than a quarter turn'); + }); + test(name + ': skull, muzzle, and ear move as one rigid head', () => { + const landmarks = [rig.head, [1235, 235], [1020, 120]]; + for (const amount of [0.25, 0.5, 1]) { + const moved = landmarks.map(point => pose.transformPoint(...point, amount, rig)); + // Center-to-landmark distance alone cannot catch different rotations + // around the same pivot. Also check the muzzle-to-ear span and vectors. + for (let a = 0; a < landmarks.length; a++) for (let b = a + 1; b < landmarks.length; b++) { + near(Math.hypot(moved[a][0] - moved[b][0], moved[a][1] - moved[b][1]), + Math.hypot(landmarks[a][0] - landmarks[b][0], landmarks[a][1] - landmarks[b][1])); + } + const angle = rig.neckAngle * pose.smooth(amount) + rig.headAngle * pose.smooth((amount - 0.08) / 0.92); + for (let i = 1; i < landmarks.length; i++) { + const dx = landmarks[i][0] - rig.head[0], dy = landmarks[i][1] - rig.head[1]; + nearPoint([moved[i][0] - moved[0][0], moved[i][1] - moved[0][1]], + [dx * Math.cos(angle) - dy * Math.sin(angle), dx * Math.sin(angle) + dy * Math.cos(angle)]); + } + } + }); + test(name + ': counter-tilt begins after neck motion without an endpoint jump', () => { + const amount = 0.04; + const center = pose.transformPoint(...rig.head, amount, rig); + const muzzle = pose.transformPoint(1235, 235, amount, rig); + near(Math.atan2(muzzle[1] - center[1], muzzle[0] - center[0]), rig.neckAngle * pose.smooth(amount)); + for (const point of [[985, 392.5], [1010, 350], rig.head, [1235, 235], [1020, 120]]) { + nearPoint(pose.transformPoint(...point, 1e-7, rig), point, 1e-8); + nearPoint(pose.transformPoint(...point, 1 - 1e-7, rig), pose.transformPoint(...point, 1, rig), 1e-8); + } + }); + test(name + ': bent neck has a curved centerline without collapsed neighboring sections', () => { + const dx = rig.head[0] - rig.root[0], dy = rig.head[1] - rig.root[1]; + const samples = [0, 0.2, 0.4, 0.6, 0.8, 1].map(s => pose.transformPoint(rig.root[0] + dx * s, rig.root[1] + dy * s, 1, rig)); + const chord = [samples[5][0] - samples[0][0], samples[5][1] - samples[0][1]]; + const chordLength = Math.hypot(...chord); + const bow = Math.max(...samples.slice(1, -1).map(p => Math.abs((p[0] - samples[0][0]) * chord[1] - (p[1] - samples[0][1]) * chord[0]) / chordLength)); + assert.ok(bow > 10, 'neck remained a straight pivoting segment'); + for (let i = 1; i < samples.length; i++) { + const distance = Math.hypot(samples[i][0] - samples[i - 1][0], samples[i][1] - samples[i - 1][1]); + assert.ok(distance > 1 && distance < Math.hypot(dx, dy), 'neighboring neck sections collapsed or stretched beyond the whole neck'); + } + }); + test(name + ': reversing a partial pose is continuous and returns upright', () => { + let amount = pose.advance(0, 1, 0.7, 2); + const before = pose.transformPoint(...rig.head, amount, rig); + nearPoint(pose.transformPoint(...rig.head, pose.advance(amount, 0, 0, 2), rig), before); + const next = pose.advance(amount, 0, 0.001, 2); + const after = pose.transformPoint(...rig.head, next, rig); + assert.ok(Math.hypot(after[0] - before[0], after[1] - before[1]) < 1); + assert.ok(after[1] < before[1], 'reversal did not raise the head'); + for (const point of [[985, 392.5], [1010, 350], [1235, 235], [1020, 120]]) { + const a = pose.transformPoint(...point, amount, rig), b = pose.transformPoint(...point, next, rig); + assert.ok(Math.hypot(a[0] - b[0], a[1] - b[1]) < 1, 'neck or skull jumped when reversing'); + } + amount = pose.advance(next, 0, 10, 2); + assert.deepEqual(pose.transformPoint(...rig.head, amount, rig), rig.head); + }); + test(name + ': skinning stays finite and joins exact body anchors continuously', () => { + for (let y = 0; y <= 1024; y += 64) for (let x = 0; x <= 1536; x += 64) { + assert.ok(pose.transformPoint(x, y, 0.6, rig).every(Number.isFinite)); + } + for (const [x, y] of [[rig.root[0] - 120, rig.head[1]], [rig.root[0] + 70, rig.root[1]]]) { + const left = pose.transformPoint(x - 1e-5, y - 1e-5, 1, rig); + const right = pose.transformPoint(x + 1e-5, y + 1e-5, 1, rig); + assert.ok(Math.hypot(left[0] - right[0], left[1] - right[1]) < 0.001); + } + }); +} + +test('centered cover fits landscape and portrait boxes without geometry drift', () => { + for (const [iw, ih, bw, bh] of [[1536, 1024, 768, 512], [1672, 941, 390, 844], [1672, 941, 1920, 700]]) { + const layout = pose.coverCentered(iw, ih, bw, bh); + assert.ok(iw * layout.scale >= bw - 1e-9 && ih * layout.scale >= bh - 1e-9); + near(layout.left + iw * layout.scale / 2, bw / 2); + near(layout.top + ih * layout.scale / 2, bh / 2); + assert.ok(Math.abs(layout.left) < 1e-9 || Math.abs(layout.top) < 1e-9); + } + assert.deepEqual(pose.coverCentered(1536, 1024, 768, 512), {scale: 0.5, left: 0, top: 0}); +}); +test('invalid numerical inputs fail explicitly instead of producing NaN geometry', () => { + assert.throws(() => pose.advance(NaN, 1, 0.1, 1), TypeError); + assert.throws(() => pose.smooth(Infinity), TypeError); + assert.throws(() => pose.coverCentered(1536, 0, 100, 100), RangeError); + assert.throws(() => pose.transformPoint(100, 100, 0.5, {root:[1,1],head:[1,1],neckAngle:1,headAngle:0}), RangeError); +}); +console.log(`${passed}/${passed} deer pose checks passed. These checks cover geometry and state contracts; visual anatomy and compositing require browser review.`); diff --git a/index.html b/index.html index 74b0b29..2cd528d 100644 --- a/index.html +++ b/index.html @@ -8,6 +8,8 @@ + +