Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 48 additions & 4 deletions docs/candidate-motion.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,54 @@ visible replay phase. Unsupported source signals, missing probes, timeouts, and
incomplete readiness fail closed. A contract proves only its named selectors,
routes, and widths; list all interactions whose parity you intend to claim.

The result retains separate facts: `source/capture motion unreproduced` and an
independent `source/candidate interaction` verdict. Without the contract,
including when comparing the portable HTML capture, unreproduced source motion
remains a hard failure. With an independently passing candidate at 390, 768 and
The result retains separate facts: `raw source/capture motion unreproduced` and an
independent `source/candidate interaction` verdict. Without a verified authored
runtime, comparison of the portable HTML capture hard-fails on source motion.
With an independently passing candidate at 390, 768 and
1440px, a candidate comparison may pass; it **does not** make the motion-free
portable artifact interactive. Per-width source/candidate traces and failures
are saved to `compare/candidate-motion-evidence.json`.

## Authoring behavior in the portable capture

An author may also include a separately implemented, self-contained runtime in
the HTML artifact, without restoring the captured site's scripts. Pass
`--portable-motion <recipe.json>` to the normal liberation command, or call
`authorPortableMotion(runDirectory, recipe)` after capturing an existing run.
The recipe is a versioned `data-liberation/portable-motion/v1` object with the
same `contract` shown above and a `routes` map:

```json
{
"schema": "data-liberation/portable-motion/v1",
"contract": { "widths": [390, 768, 1440], "routes": { "/": { "ready": { "source": "body:not(.loading)", "candidate": "[data-motion-ready=true]" }, "text": ["#status"], "canvases": ["#drawing"], "clicks": [{"trigger":"#replay", "target":"#status"}] } } },
"routes": {
"/": {
"elements": [{"selector":"#drawing", "attributes":{"data-motion-effect":"ripple"}}],
"busyHidden": ["#status-indicator"],
"markers": [{"attribute":"data-motion-sequence", "value":{"target":"#status"}}],
"scripts": [{"path":"/absolute/path/to/independently-authored.js", "sha256":"<64-character SHA-256>"}]
}
}
}
```

`elements` only adds `data-*` attributes to one existing captured element.
`busyHidden` is optional: a named element remains hidden while an authored
finite sequence sets `body[aria-busy=true]`. `markers` carry inert JSON
configuration for authored runtimes. The scripts must be independently authored,
outside the capture run, and hash-pinned. Any script whose hash matches captured
source code is rejected. No WordPress or destination runtime is shipped by Data
Liberation; the author chooses reusable scripts that read text from the DOM so
edits remain meaningful. Routes must have an unreproduced source-interactivity
diagnosis. The site is staged, checked offline and verified against the live
source at the contract's widths before the public `website/` tree is changed.
Failure leaves the original capture intact. The successful `portable-motion.json`
receipt stores relative script paths and hashes, not absolute source paths.

Thereafter plain `data-liberation compare <run-dir>` checks those hashes and
**reruns** the live source-versus-portable runtime contract. A missing/changed
script or failed startup, canvas, clock, visibility or click probe does not
inherit the earlier pass. The report distinguishes the removed raw source script
from the separately authored, proven interactive portable output. An exported
`website/` directory remains runnable without the CLI or any network access.
9 changes: 9 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ const HELP = `
interrupted, for browsing it. Liberation writes the site
and exits without this.
--no-learn-fluid Skip the width sweep and freeze the layout at one width.
--portable-motion <json> Author a portable runtime from pinned independent
scripts and verify it against the live source before export.
Learning is on by default: it keeps the copy reflowing
like the source instead of pinning it to the capture width.

Expand Down Expand Up @@ -193,12 +195,19 @@ if (args.includes('--help')) {
}

const { liberateSite } = await import('./ui/liberate.js');
const portablePath = getArg('--portable-motion');
if (args.includes('--portable-motion') && !portablePath) {
console.error('Error: --portable-motion requires a JSON recipe file.');
process.exit(1);
}
const portableMotion = portablePath ? JSON.parse((await import('node:fs')).readFileSync(portablePath, 'utf8')) : undefined;
const result = await liberateSite({
url,
outputBase: getArg('--output') || resolveOutputBase(),
resume: args.includes('--resume'),
screenshots: args.includes('--screenshots'),
learnFluid: !args.includes('--no-learn-fluid'),
portableMotion,
serve: args.includes('--serve'),
log: (message) => process.stderr.write(`${message}\n`),
});
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export type { CaptureOptions, CaptureResult, CaptureProgress, CaptureDependencie
export { checkFidelity } from './lib/fidelity/check.js';
export type { FidelityCheckOptions, FidelityReport, RouteScore, ObservePair } from './lib/fidelity/check.js';
export type { MotionContract, MotionEvidence } from './lib/fidelity/candidate-motion.js';
export { authorPortableMotion } from './lib/portable-motion.js';
export type { PortableMotionRecipe, PortableMotionReceipt } from './lib/portable-motion.js';
export type {
DetectionResult,
FullDetectionResult,
Expand Down
28 changes: 24 additions & 4 deletions src/lib/fidelity/candidate-motion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export interface MotionContract {
routes: Record< string, {
ready: { source: string; candidate: string };
text: string[];
/** Compare visibly present/hidden elements during startup and after readiness. */
visibility?: string[];
/** Volatile clock digits are compared to each page's own observation time, not across a minute boundary. */
clock?: { hour: string; minute: string; format: '12h' | '24h' };
clicks: Array< { trigger: string; target: string } >;
Expand Down Expand Up @@ -47,6 +49,10 @@ export function validateMotionContract( contract: MotionContract ): void {
throw new Error( `Invalid motion contract route: ${ route }` );
}
validateSelectors( [ probe.ready.source, probe.ready.candidate, ...probe.text, ...probe.canvases ] );
if ( probe.visibility ) {
if ( ! Array.isArray( probe.visibility ) || probe.visibility.length > 16 ) throw new Error( `Invalid visibility probes: ${ route }` );
validateSelectors( probe.visibility );
}
if ( probe.clock ) {
validateSelectors( [ probe.clock.hour, probe.clock.minute ] );
if ( ! probe.text.includes( probe.clock.hour ) || ! probe.text.includes( probe.clock.minute ) || ! [ '12h', '24h' ].includes( probe.clock.format ) ) {
Expand All @@ -60,7 +66,7 @@ export function validateMotionContract( contract: MotionContract ): void {
}
}

async function visit( page: Page, url: string, ready: string, text: string[] ): Promise< { values: Record< string, string >; changes: Record< string, string[] >; observedAt: number } > {
async function visit( page: Page, url: string, ready: string, text: string[], visibility: string[] ): Promise< { values: Record< string, string >; changes: Record< string, string[] >; observedAt: number; initialVisibility: Record< string, boolean >; finalVisibility: Record< string, boolean > } > {
await page.addInitScript( ( selectors ) => {
const start = () => {
const changes = Object.fromEntries( selectors.map( ( selector ) => [ selector, [] as string[] ] ) );
Expand All @@ -80,9 +86,14 @@ async function visit( page: Page, url: string, ready: string, text: string[] ):
else start();
}, text );
await page.goto( url, { waitUntil: 'domcontentloaded', timeout: 30_000 } );
await page.waitForTimeout( 200 );
const initialVisibility = await page.evaluate( ( selectors ) => Object.fromEntries( selectors.map( ( selector ) => {
const element = document.querySelector( selector );
return [ selector, !! element && getComputedStyle( element ).display !== 'none' && getComputedStyle( element ).visibility !== 'hidden' ];
} ) ), visibility );
await page.waitForSelector( ready, { state: 'attached', timeout: 25_000 } );
await page.waitForTimeout( 150 );
return page.evaluate( ( selectors ) => {
const result = await page.evaluate( ( selectors ) => {
const trace = ( window as typeof window & { __dlaMotion?: { changes: Record< string, string[] >; sample: () => void } } ).__dlaMotion;
trace?.sample();
return {
Expand All @@ -91,6 +102,11 @@ async function visit( page: Page, url: string, ready: string, text: string[] ):
observedAt: Date.now(),
};
}, text );
const finalVisibility = await page.evaluate( ( selectors ) => Object.fromEntries( selectors.map( ( selector ) => {
const element = document.querySelector( selector );
return [ selector, !! element && getComputedStyle( element ).display !== 'none' && getComputedStyle( element ).visibility !== 'hidden' ];
} ) ), visibility );
return { ...result, initialVisibility, finalVisibility };
}

function validClock( observation: { values: Record< string, string >; observedAt: number }, clock: NonNullable< RouteContract[ 'clock' ] > ): boolean {
Expand Down Expand Up @@ -180,10 +196,14 @@ export async function verifyCandidateMotion(
const candidatePage = await browser.newPage( { viewport: { width: viewport, height: 900 } } );
try {
const [ original, copy ] = await Promise.all( [
visit( sourcePage, source, contract.ready.source, contract.text ),
visit( candidatePage, candidate, contract.ready.candidate, contract.text ),
visit( sourcePage, source, contract.ready.source, contract.text, contract.visibility ?? [] ),
visit( candidatePage, candidate, contract.ready.candidate, contract.text, contract.visibility ?? [] ),
] );
observations.text = { source: original, candidate: copy };
for ( const selector of contract.visibility ?? [] ) {
if ( original.initialVisibility[ selector ] !== copy.initialVisibility[ selector ] ) failures.push( `startup visibility differs: ${ selector }` );
if ( original.finalVisibility[ selector ] !== copy.finalVisibility[ selector ] ) failures.push( `settled visibility differs: ${ selector }` );
}
if ( contract.clock ) {
if ( ! validClock( original, contract.clock ) ) failures.push( 'source clock is not visitor-local time' );
if ( ! validClock( copy, contract.clock ) ) failures.push( 'candidate clock is not visitor-local time' );
Expand Down
24 changes: 15 additions & 9 deletions src/lib/fidelity/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from '../screenshot/page-helpers.js';
import { applySourceCleanup, readSourceCleanup, validateCleanupPolicy, type CleanupPolicy, type CleanupReport } from '../source-cleanup.js';
import { runFidelityChecks } from './checks.js';
import { readPortableMotion } from '../portable-motion.js';
import { validateMotionContract, verifyCandidateMotion, type MotionContract, type MotionEvidence } from './candidate-motion.js';
import { probeDialogs } from './dialog-probe.js';
import { writePixelEvidence } from './evidence.js';
Expand Down Expand Up @@ -135,6 +136,8 @@ function candidateBase( candidateUrl: string ): string {
export type RouteScore = ViewportScore & { route: string };

export interface FidelityReport {
/** Authored portable runtime was verified during this comparison, not inferred from source scripts. */
portableMotion?: { verified: boolean; routes: string[] };
cleanup?: { policy: CleanupPolicy; source: CleanupReport[] };
/** Overlays dismissed per side before measuring. Evidence, never a gate. */
overlays: OverlayRecord[];
Expand Down Expand Up @@ -807,9 +810,11 @@ export async function checkFidelity( options: FidelityCheckOptions ): Promise< F
}

const candidate = options.candidateUrl === undefined ? null : candidateBase( options.candidateUrl );
if ( options.motionContract ) {
if ( ! candidate || options.observe ) throw new Error( 'Motion contract requires a live --candidate browser comparison' );
validateMotionContract( options.motionContract );
const portable = candidate || options.motionContract ? null : readPortableMotion( dirname( receiptPath ), websiteDir );
const motionContract = options.motionContract ?? portable?.contract;
if ( motionContract ) {
if ( ( ! candidate && ! portable ) || options.observe ) throw new Error( 'Motion contract requires a live --candidate browser comparison or an authored portable runtime receipt' );
validateMotionContract( motionContract );
}
let observe = options.observe;
const browser = observe ? null : await (await import('playwright')).chromium.launch();
Expand Down Expand Up @@ -891,9 +896,9 @@ export async function checkFidelity( options: FidelityCheckOptions ): Promise< F
const sourceHref = sources.get( route )!;
const localHref = `${ candidate ?? server?.url ?? 'http://liberated.invalid' }${ route }`;
const signals = unreproducedMotion.get( sourceHref );
const contract = signals && options.motionContract?.routes[ route ];
const contract = signals && motionContract?.routes[ route ];
if ( signals && contract && browser ) {
for ( const width of options.motionContract!.widths ) {
for ( const width of motionContract!.widths ) {
log( `[compare] ${ route } @ ${ width }px source/candidate motion` );
motionEvidence.push( await verifyCandidateMotion( browser, route, width, sourceHref, localHref, contract, signals ) );
}
Expand Down Expand Up @@ -925,7 +930,7 @@ export async function checkFidelity( options: FidelityCheckOptions ): Promise< F
checked.failures.push( `candidate retains advertising or source attribution (${ pair.candidateRetained } removable)` );
}
if ( signals && ! candidateMotionVerified ) checked.failures.push( `source motion not reproduced by capture: ${ signals.join( ', ' ) }; candidate behavior unverified` );
if ( candidateMotionVerified ) checked.notes.push( 'source/capture motion unreproduced; independent source/candidate interaction verified at 390/768/1440px' );
if ( candidateMotionVerified ) checked.notes.push( portable ? 'raw source/capture motion unreproduced; authored portable runtime independently verified at 390/768/1440px' : 'source/capture motion unreproduced; independent source/candidate interaction verified at 390/768/1440px' );
const score: RouteScore = {
route,
viewport: width,
Expand Down Expand Up @@ -986,7 +991,7 @@ export async function checkFidelity( options: FidelityCheckOptions ): Promise< F
score.failures.push( `source motion not reproduced by capture: ${ signals.join( ', ' ) }; candidate behavior unverified` );
score.pass = false;
}
if ( candidateMotionVerified ) score.notes.push( 'source/capture motion unreproduced; independent candidate interaction verified' );
if ( candidateMotionVerified ) score.notes.push( portable ? 'raw capture motion unreproduced; authored portable runtime verified' : 'source/capture motion unreproduced; independent candidate interaction verified' );
score.notes.push( 'interactivity' );
scores.push( score );
}
Expand All @@ -1007,7 +1012,7 @@ export async function checkFidelity( options: FidelityCheckOptions ): Promise< F
join(evidenceDir, 'overlay-evidence.json'),
JSON.stringify({ schema: 'data-liberation/compare-overlays/v1', completed: comparisonCompleted, kinds: COMPARED_OVERLAY_KINDS, observations: overlays }, null, 2)
);
if ( options.motionContract ) writeFileSync( join( evidenceDir, 'candidate-motion-evidence.json' ),
if ( motionContract ) writeFileSync( join( evidenceDir, 'candidate-motion-evidence.json' ),
JSON.stringify( { schema: 'data-liberation/candidate-motion/v1', completed: comparisonCompleted, observations: motionEvidence }, null, 2 ) );
}

Expand Down Expand Up @@ -1035,7 +1040,8 @@ export async function checkFidelity( options: FidelityCheckOptions ): Promise< F
routesCleanupUnproven: [ ...unproven ].sort(),
selfConsistency,
scores,
...( options.motionContract ? { motionEvidence } : {} ),
...( motionContract ? { motionEvidence } : {} ),
...( portable ? { portableMotion: { verified: motionEvidence.length > 0 && motionEvidence.every( ( evidence ) => evidence.pass ), routes: Object.keys( portable.routes ) } } : {} ),
...summary,
};
}
Loading
Loading