diff --git a/README.md b/README.md index d71fd8f..f008eaa 100644 --- a/README.md +++ b/README.md @@ -213,7 +213,7 @@ npm run site:build That command publishes static paths for `/`, `/value/`, `/docs/`, `/examples/`, and `/install` under `site/dist/`. The public `/mcp` and `/mcp/` routes are served by the hosted Streamable HTTP MCP function and return metadata for browser GET requests. -The homepage opens with one visual-composition video above the existing hero and automatically selects distinct light or dark recordings and posters from the visitor's color-scheme preference. The public player does not expose or publish a caption track. The video is replay evidence, not proof that every policy shown is active on the public MCP. There is no separate visual-composition release or interactive-demo route. +The homepage opens with one visual-composition video above the existing hero. It follows an initial generated draft through browser diagnosis and measured repair, and automatically selects distinct light or dark recordings and posters from the visitor's color-scheme preference. The public player does not expose or publish a caption track. The video is replay evidence, not proof that every policy shown is active on the public MCP. There is no separate visual-composition release or interactive-demo route. For local site review with the same `/mcp` behavior: diff --git a/scripts/visual-composition-film/README.md b/scripts/visual-composition-film/README.md new file mode 100644 index 0000000..d87199b --- /dev/null +++ b/scripts/visual-composition-film/README.md @@ -0,0 +1,7 @@ +# Visual composition film + +`visual-composition-runtime-demo.html` is the editable source for the light and dark homepage films. The story starts with Draftling assembling a plausible first draft too quickly, then hands the same artifact to the Judgment agent for browser review and measured repair. + +`generate-draftling-prelude.mjs` produces the original opening music used for Draftling's entrance and three build actions. The published, caption-free films and posters remain under `site/assets/releases/`. + +Keep the light and dark captures on the same 38.2-second timeline so the homepage can switch themes without moving the playhead to a different story beat. diff --git a/scripts/visual-composition-film/generate-draftling-prelude.mjs b/scripts/visual-composition-film/generate-draftling-prelude.mjs new file mode 100644 index 0000000..ba44979 --- /dev/null +++ b/scripts/visual-composition-film/generate-draftling-prelude.mjs @@ -0,0 +1,163 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const OUTPUT = process.argv[2] + ? path.resolve(process.argv[2]) + : path.join( + ROOT, + "output/playwright/public-mcp-demo/judgmentkit-draftling-prelude.wav", + ); +const SAMPLE_RATE = 48_000; +const CHANNELS = 2; +const DURATION_SECONDS = 38.2; +const FRAME_COUNT = Math.round(SAMPLE_RATE * DURATION_SECONDS); +const left = new Float64Array(FRAME_COUNT); +const right = new Float64Array(FRAME_COUNT); + +function mixSample(frame, sample, pan = 0) { + if (frame < 0 || frame >= FRAME_COUNT) return; + const normalizedPan = Math.max(-1, Math.min(1, pan)); + const angle = ((normalizedPan + 1) * Math.PI) / 4; + left[frame] += sample * Math.cos(angle); + right[frame] += sample * Math.sin(angle); +} + +function addPluck(time, frequency, { + duration = 0.42, + gain = 0.13, + pan = 0, +} = {}) { + const start = Math.round(time * SAMPLE_RATE); + const length = Math.round(duration * SAMPLE_RATE); + for (let offset = 0; offset < length; offset += 1) { + const t = offset / SAMPLE_RATE; + const envelope = Math.sin(Math.min(1, t / 0.018) * Math.PI / 2) + * Math.exp((-7.2 * t) / duration); + const body = Math.sin(2 * Math.PI * frequency * t) + + 0.28 * Math.sin(2 * Math.PI * frequency * 2 * t + 0.18) + + 0.08 * Math.sin(2 * Math.PI * frequency * 3 * t + 0.42); + mixSample(start + offset, gain * envelope * body, pan); + } +} + +let noiseState = 0x4a4b4452; +function deterministicNoise() { + noiseState ^= noiseState << 13; + noiseState ^= noiseState >>> 17; + noiseState ^= noiseState << 5; + return ((noiseState >>> 0) / 0xffffffff) * 2 - 1; +} + +function addStamp(time, frequency = 142, pan = 0) { + const start = Math.round(time * SAMPLE_RATE); + const duration = 0.19; + const length = Math.round(duration * SAMPLE_RATE); + for (let offset = 0; offset < length; offset += 1) { + const t = offset / SAMPLE_RATE; + const envelope = Math.exp(-24 * t); + const pitch = frequency * (1 - 0.28 * (t / duration)); + const thump = Math.sin(2 * Math.PI * pitch * t); + const texture = deterministicNoise() * Math.exp(-58 * t); + mixSample(start + offset, (0.17 * thump + 0.055 * texture) * envelope, pan); + } +} + +function addAirSweep(time, duration = 0.68, panStart = -0.6, panEnd = 0.72) { + const start = Math.round(time * SAMPLE_RATE); + const length = Math.round(duration * SAMPLE_RATE); + let previous = 0; + for (let offset = 0; offset < length; offset += 1) { + const t = offset / SAMPLE_RATE; + const progress = t / duration; + const attack = Math.min(1, progress / 0.22); + const release = Math.max(0, 1 - progress); + const raw = deterministicNoise(); + const filtered = previous * 0.86 + raw * 0.14; + previous = filtered; + const shimmer = Math.sin(2 * Math.PI * (620 + 760 * progress) * t) * 0.18; + mixSample( + start + offset, + (filtered * 0.045 + shimmer * 0.035) * attack * release, + panStart + (panEnd - panStart) * progress, + ); + } +} + +function addWarmBed(startTime, endTime) { + const start = Math.round(startTime * SAMPLE_RATE); + const end = Math.min(FRAME_COUNT, Math.round(endTime * SAMPLE_RATE)); + for (let frame = start; frame < end; frame += 1) { + const t = (frame - start) / SAMPLE_RATE; + const duration = endTime - startTime; + const fadeIn = Math.min(1, t / 0.75); + const fadeOut = Math.min(1, Math.max(0, (duration - t) / 0.9)); + const envelope = 0.025 * fadeIn * fadeOut; + const body = Math.sin(2 * Math.PI * 196 * t) + + 0.45 * Math.sin(2 * Math.PI * 293.66 * t + 0.4); + mixSample(frame, envelope * body, -0.08); + } +} + +addWarmBed(0, 6.55); + +[ + [0.42, 392.0, -0.55], + [0.72, 493.88, -0.22], + [1.03, 587.33, 0.18], + [1.34, 659.25, 0.48], + [2.08, 493.88, -0.38], + [2.38, 587.33, -0.04], + [2.68, 739.99, 0.34], + [3.38, 440.0, -0.46], + [3.69, 554.37, -0.08], + [4.0, 659.25, 0.38], + [4.72, 587.33, -0.2], + [4.98, 739.99, 0.18], + [5.24, 880.0, 0.5], +].forEach(([time, frequency, pan]) => addPluck(time, frequency, { pan })); + +addStamp(1.4, 138, -0.45); +addStamp(2.5, 152, -0.1); +addStamp(3.9, 166, 0.48); +addPluck(5.18, 783.99, { duration: 0.58, gain: 0.15, pan: -0.32 }); +addPluck(5.3, 987.77, { duration: 0.62, gain: 0.13, pan: 0.06 }); +addPluck(5.42, 1174.66, { duration: 0.68, gain: 0.115, pan: 0.4 }); +addAirSweep(5.92, 0.72, -0.35, 0.78); + +let peak = 0; +for (let frame = 0; frame < FRAME_COUNT; frame += 1) { + peak = Math.max(peak, Math.abs(left[frame]), Math.abs(right[frame])); +} +const normalization = peak > 0 ? Math.min(1, 0.72 / peak) : 1; +const dataBytes = FRAME_COUNT * CHANNELS * 2; +const buffer = Buffer.alloc(44 + dataBytes); +buffer.write("RIFF", 0); +buffer.writeUInt32LE(36 + dataBytes, 4); +buffer.write("WAVE", 8); +buffer.write("fmt ", 12); +buffer.writeUInt32LE(16, 16); +buffer.writeUInt16LE(1, 20); +buffer.writeUInt16LE(CHANNELS, 22); +buffer.writeUInt32LE(SAMPLE_RATE, 24); +buffer.writeUInt32LE(SAMPLE_RATE * CHANNELS * 2, 28); +buffer.writeUInt16LE(CHANNELS * 2, 32); +buffer.writeUInt16LE(16, 34); +buffer.write("data", 36); +buffer.writeUInt32LE(dataBytes, 40); + +let cursor = 44; +for (let frame = 0; frame < FRAME_COUNT; frame += 1) { + const l = Math.max(-1, Math.min(1, left[frame] * normalization)); + const r = Math.max(-1, Math.min(1, right[frame] * normalization)); + buffer.writeInt16LE(Math.round(l * 32767), cursor); + buffer.writeInt16LE(Math.round(r * 32767), cursor + 2); + cursor += 4; +} + +fs.mkdirSync(path.dirname(OUTPUT), { recursive: true }); +fs.writeFileSync(OUTPUT, buffer); +process.stdout.write(`${OUTPUT}\n`); diff --git a/scripts/visual-composition-film/visual-composition-runtime-demo.html b/scripts/visual-composition-film/visual-composition-runtime-demo.html new file mode 100644 index 0000000..2294bbc --- /dev/null +++ b/scripts/visual-composition-film/visual-composition-runtime-demo.html @@ -0,0 +1,2601 @@ + + + + + + + + JudgmentKit — Visual composition runtime demo + + + + +
+
+
+

Catch subtle UI defects.

+ +
+
Candidate under review
+
+
+
+ + Brownfield safety design + + +
+ +
+ + + +
+
+ + +
+
+
+ + +
+
+ + + + diff --git a/site/assets/releases/judgmentkit-select-field-agent-demo-dark.mp4 b/site/assets/releases/judgmentkit-select-field-agent-demo-dark.mp4 index 8b0e34f..4087de2 100644 Binary files a/site/assets/releases/judgmentkit-select-field-agent-demo-dark.mp4 and b/site/assets/releases/judgmentkit-select-field-agent-demo-dark.mp4 differ diff --git a/site/assets/releases/judgmentkit-select-field-agent-demo-poster-dark.png b/site/assets/releases/judgmentkit-select-field-agent-demo-poster-dark.png index 76f2b31..cb57c94 100644 Binary files a/site/assets/releases/judgmentkit-select-field-agent-demo-poster-dark.png and b/site/assets/releases/judgmentkit-select-field-agent-demo-poster-dark.png differ diff --git a/site/assets/releases/judgmentkit-select-field-agent-demo-poster.png b/site/assets/releases/judgmentkit-select-field-agent-demo-poster.png index c85e8e3..19098e2 100644 Binary files a/site/assets/releases/judgmentkit-select-field-agent-demo-poster.png and b/site/assets/releases/judgmentkit-select-field-agent-demo-poster.png differ diff --git a/site/assets/releases/judgmentkit-select-field-agent-demo.mp4 b/site/assets/releases/judgmentkit-select-field-agent-demo.mp4 index 8c2224e..6f7a33e 100644 Binary files a/site/assets/releases/judgmentkit-select-field-agent-demo.mp4 and b/site/assets/releases/judgmentkit-select-field-agent-demo.mp4 differ diff --git a/site/build-site.mjs b/site/build-site.mjs index fffe7da..ac3fed8 100755 --- a/site/build-site.mjs +++ b/site/build-site.mjs @@ -4580,7 +4580,7 @@ function homepage() { return page( "JudgmentKit", ` -
+
Your browser cannot play this video. Download the demo video. diff --git a/tests/site.test.mjs b/tests/site.test.mjs index 2e02b1d..fbcc666 100644 --- a/tests/site.test.mjs +++ b/tests/site.test.mjs @@ -705,6 +705,11 @@ assert.ok( assert.match(homepageFilmOpenTag, /(?:\s|^)controls(?:\s|>)/); assert.match(homepageFilmOpenTag, /(?:\s|^)playsinline(?:\s|>)/); assert.match(homepageFilmOpenTag, /preload="metadata"/); +assert.match( + homepageFilmOpenTag, + /aria-label="JudgmentKit UI generation, diagnosis, and measured repair"/, + "the film description should cover the generated draft as well as its review and repair", +); assert.match( homepageFilmOpenTag, /poster="\/assets\/releases\/judgmentkit-select-field-agent-demo-poster\.png"/, @@ -3057,6 +3062,267 @@ assert.equal(homepageHeroArt.subarray(8, 12).toString("ascii"), "WEBP"); assert.ok(homepageHeroArt.length > 0); assert.ok(homepageHeroArt.length < 250_000); +const visualCompositionFilmSource = fs.readFileSync( + new URL( + "../scripts/visual-composition-film/visual-composition-runtime-demo.html", + import.meta.url, + ), + "utf8", +); +assert.match(visualCompositionFilmSource, /data-agent="draftling"/); +assert.match(visualCompositionFilmSource, /data-agent="judgment"/); +assert.match( + visualCompositionFilmSource, + /\.cinematic-cursor\s*>\s*\.agent-guide\s*\{[^}]*width:\s*48px;[^}]*height:\s*54px;[^}]*overflow:\s*visible;/s, + "Judgment actor sizing should target only its direct agent-guide SVG", +); +assert.doesNotMatch( + visualCompositionFilmSource, + /\.cinematic-cursor\s+svg\s*\{/, + "a broad cinematic-cursor SVG rule would also resize SVGs inside the cloned candidate UI", +); +const judgmentMagnifierStart = visualCompositionFilmSource.indexOf(''); +const judgmentMagnifierEnd = visualCompositionFilmSource.indexOf( + '', + judgmentMagnifierStart, +); +const judgmentMagnifierMarkup = visualCompositionFilmSource.slice( + judgmentMagnifierStart, + judgmentMagnifierEnd, +); +assert.match( + visualCompositionFilmSource, + /class="agent-magnifier-lens"/, + "the repair agent should visibly carry its inspection prop", +); +const judgmentMagnifierRadius = Number.parseFloat( + judgmentMagnifierMarkup.match(/class="agent-magnifier-lens"[^>]*\br="([\d.]+)"/)?.[1] ?? "0", +); +assert.ok( + judgmentMagnifierRadius >= 10, + "the repair agent magnifier should remain large enough to read at homepage-film scale", +); +assert.match( + visualCompositionFilmSource, + /const magnifierExpandedScale = 2\.6;/, + "the repair agent should deploy a deliberately oversized inspection lens", +); +assert.match( + visualCompositionFilmSource, + /@keyframes agent-magnifier-pullout[\s\S]*scale\(0\.28\)[\s\S]*scale\(2\.78\)[\s\S]*scale\(2\.6\)/, + "the oversized lens should visibly leave its stowed position, overshoot, and settle", +); +assert.match( + visualCompositionFilmSource, + /class="agent-live-lens"[^>]*id="agent-live-lens"[\s\S]*class="agent-live-lens-scene"[^>]*id="agent-live-lens-scene"/, + "the inspection prop should provide a clipped HTML viewport for the real candidate UI", +); +assert.match( + judgmentMagnifierMarkup, + /class="agent-magnifier-lens"[\s\S]*class="agent-magnifier-ring"[\s\S]*class="agent-magnifier-shine"/, + "the SVG prop should retain only its physical lens, ring, and shine", +); +assert.doesNotMatch( + judgmentMagnifierMarkup, + /agent-lens-scene|>Aa<|>28 slot<|>6 inset= 0 && liveLensSanitizationEnd > liveLensSanitizationStart, + "the visual-only clone should sanitize itself and every descendant before mounting", +); +assert.match( + liveLensSanitizationSource, + /name\.startsWith\('on'\)[\s\S]*node\.removeAttribute\(name\)/, + "the visual-only clone should discard copied event-handler attributes", +); +for (const attribute of [ + "id", + "role", + "tabindex", + "aria-label", + "aria-expanded", + "name", + "for", + "href", + "data-demo-geometry", + "data-part", + "data-measure", +]) { + assert.match( + liveLensSanitizationSource, + new RegExp(`['"]${attribute}['"]`), + `the visual-only clone should remove ${attribute} from copied DOM`, + ); +} +assert.match( + liveLensSanitizationSource, + /node\.removeAttribute\(attribute\)/, + "the visual-only clone should remove duplicate semantics and geometry hooks from every descendant", +); +assert.doesNotMatch( + visualCompositionFilmSource, + /document\.querySelector\(['"](?:\.bad-ui|\.bad-lockup(?:-text)?|\.bad-select|\.bad-symbol|\.select-trailing|\[data-demo-geometry="value"\])/, + "film actions must stay scoped to the retained source UI so the earlier lens clone cannot steal selectors", +); +assert.match( + visualCompositionFilmSource, + /liveLensSource\.querySelector\('\.bad-symbol'\)[\s\S]*liveLensSource\.querySelector\('\.bad-lockup-text'\)[\s\S]*liveLensSource\.querySelector\('\[data-demo-geometry="value"\]'\)[\s\S]*liveLensSource\.querySelector\('\.select-trailing'\)/, + "measured repair traces should resolve every geometry target from the retained source UI", +); +assert.match( + visualCompositionFilmSource, + /const sourceRect = liveLensSource\.getBoundingClientRect\(\);[\s\S]*const targetRect = target\.getBoundingClientRect\(\);[\s\S]*x:\s*targetRect\.left \+ targetRect\.width \* anchorX[\s\S]*y:\s*targetRect\.top \+ targetRect\.height \* anchorY[\s\S]*x:\s*point\.x - sourceRect\.left[\s\S]*y:\s*point\.y - sourceRect\.top/, + "the live close-up should derive its focus from the real source and inspected target rectangles", +); +assert.match( + visualCompositionFilmSource, + /liveLensClone\.style\.width\s*=\s*`\$\{sourceRect\.width\}px`;[\s\S]*liveLensClone\.style\.height\s*=\s*`\$\{sourceRect\.height\}px`;/, + "the cloned candidate should retain the real source border-box geometry inside the lens", +); +assert.match( + visualCompositionFilmSource, + /liveLensScene\.style\.transform\s*=\s*`translate(?:3d)?\([\s\S]*scale\(\$\{liveLensOpticalZoom\}\)`;/, + "the live lens should translate the inspected source point to its center before applying optical zoom", +); +assert.match( + visualCompositionFilmSource, + /\.agent-live-lens\s*\{[^}]*overflow:\s*hidden;[^}]*border-radius:\s*50%;[^}]*opacity:\s*0;/s, + "the live close-up should remain clipped and invisible while the prop is stowed", +); +assert.match( + visualCompositionFilmSource, + /data-lens-pose="stowed"/, + "the Judgment agent should begin with the live lens explicitly stowed", +); +assert.match( + visualCompositionFilmSource, + /const magnifierHotspot = Object\.freeze\(\{[\s\S]*magnifierExpandedScale[\s\S]*function moveLens\(/, + "the film should position the close-up from the lens center rather than the opaque agent body", +); +assert.match( + visualCompositionFilmSource, + /function prepareLens\([\s\S]*function pullLens\([\s\S]*function stowLens\(/, + "the lens should have explicit approach, pull-out, and stow behaviors", +); +assert.match( + visualCompositionFilmSource, + /alignmentApproach:[\s\S]*alignmentLensOut:[\s\S]*alignmentLensStow:[\s\S]*caretApproach:[\s\S]*caretLensOut:[\s\S]*caretLensStow:/, + "each defect close-up should be framed by a complete physical prop gesture", +); +const judgmentEntryStart = visualCompositionFilmSource.indexOf( + "cinematicLater(cinematicTimeline.judgmentEnters", +); +const judgmentEntryEnd = visualCompositionFilmSource.indexOf( + "cinematicLater(cinematicTimeline.startButtonHover", + judgmentEntryStart, +); +const judgmentEntrySource = visualCompositionFilmSource.slice( + judgmentEntryStart, + judgmentEntryEnd, +); +assert.ok( + judgmentEntryStart >= 0 && judgmentEntryEnd > judgmentEntryStart, + "the cinematic should retain a bounded Judgment-agent entrance cue", +); +assert.match( + judgmentEntrySource, + /setLensPose\('stowed'\)/, + "the Judgment agent should enter with the live close-up explicitly stowed", +); +assert.doesNotMatch( + judgmentEntrySource, + /pullLens\(|setLensPose\(['"](?:pulling|out)['"]\)/, + "the live close-up must not pre-show when the Judgment agent enters", +); +const cinematicCue = (name) => Number.parseInt( + visualCompositionFilmSource.match(new RegExp(`${name}:\\s*(\\d+)`))?.[1] ?? "-1", + 10, +); +assert.ok( + cinematicCue("diagnosisFailed") < cinematicCue("alignmentLensOut") + && cinematicCue("alignmentLensStow") < cinematicCue("caretLensOut"), + "the live close-up must not deploy before failure and must stow between the two inspections", +); +assert.match( + visualCompositionFilmSource, + /body\[data-state="failed"\] \.agent-magnifier-ring,[^{]*\{[^}]*stroke:\s*var\(--red\);/s, + "the magnifier should reinforce the detected-failure state", +); +assert.match( + visualCompositionFilmSource, + /\.cinematic-cursor\.success \.agent-guide \.agent-magnifier-ring,[^{]*\{[^}]*stroke:\s*var\(--green\);/s, + "the magnifier should resolve with the accepted state", +); +assert.match(visualCompositionFilmSource, /data-build-stage="ready"/); +assert.match( + visualCompositionFilmSource, + /artifact-card\[data-build-stage="empty"\] \.bad-lockup,[\s\S]*artifact-card\[data-build-stage="empty"\] \.select-demo-shell[\s\S]*opacity:\s*0;[\s\S]*transition:\s*none;/, + "the cold-open UI children should be hidden before the frame appears so they cannot flash during the first tap", +); +assert.match( + visualCompositionFilmSource, + /artifact-card\[data-build-stage="empty"\] \.bad-ui\s*\{[^}]*opacity:\s*0;[^}]*visibility:\s*hidden;[^}]*transition:\s*none;/s, + "the complete candidate should be unpainted during the cinematic cold open", +); +assert.match( + visualCompositionFilmSource, + /artifact-card\[data-build-stage="frame"\] \.bad-lockup,[\s\S]*artifact-card\[data-build-stage="frame"\] \.select-demo-shell,[\s\S]*artifact-card\[data-build-stage="lockup"\] \.select-demo-shell[\s\S]*opacity:\s*0;/, + "each later candidate part should remain hidden until its own Draftling build cue", +); +const startCinematicSource = visualCompositionFilmSource.slice( + visualCompositionFilmSource.indexOf("function startCinematic()"), + visualCompositionFilmSource.indexOf("function reset(", visualCompositionFilmSource.indexOf("function startCinematic()")), +); +assert.ok( + startCinematicSource.indexOf("artifactCard.dataset.buildStage = 'empty'") + < startCinematicSource.indexOf("cinematicLater(cinematicTimeline.draftlingEnters"), + "the empty build stage should be committed before any cinematic build timer can run", +); +assert.match(visualCompositionFilmSource, /First draft ready/); +assert.match(visualCompositionFilmSource, /judgmentkit-demo-cinematic-complete/); +assert.match( + visualCompositionFilmSource, + /class="candidate-stack"[\s\S]*class="bad-ui"[\s\S]*class="stage-footer"[\s\S]*id="run-review"/, + "the film action should remain in the candidate stack immediately after the measured UI", +); +assert.match( + visualCompositionFilmSource, + /\.candidate-stack\s*\{[^}]*width:\s*min\(570px,\s*calc\(100% - 56px\)\);[^}]*gap:\s*14px;/s, + "the film action should sit in a compact, fixed-gap stack with the candidate", +); +assert.match( + visualCompositionFilmSource, + /\.stage-footer\s*\{[^}]*justify-content:\s*flex-end;[^}]*margin-top:\s*0;/s, + "the film action should align to the candidate edge without a detached footer margin", +); +assert.match( + visualCompositionFilmSource, + /draftlingEnters:\s*500[\s\S]*judgmentEnters:\s*7300[\s\S]*complete:\s*38000/, + "the film source should preserve the generation-to-judgment story handoff", +); + const releaseRecordingSource = fs.readFileSync( new URL("../site/assets/releases/judgmentkit-select-field-agent-demo.mp4", import.meta.url), );