@penpair/reflection-check 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Niklas Westman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,32 @@
1
- # Temporary Holding Version
1
+ # @penpair/reflection-check
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The recommended companion when you implement a frontend from Penpot and want repeatable visual checks against approved designs. It is an optional package installed alongside one command package at the same reviewed version and registered in `adapters`.
4
+
5
+ **Penpot design → approved reference and handoff → frontend code → browser capture → comparison → owner review.** Start with the [companion setup guide](https://github.com/niklas-westman/penpair/blob/main/docs/adapters.md#check-the-frontend-against-the-design) for installation, registration and the readiness checklist. Installing the package does not configure application state or supply a browser provider.
6
+
7
+ It depends on the neutral core; the core does not load or depend on this adapter.
8
+
9
+ The existing generated Reflection `visualSmoke` configuration remains the simple route for legacy cases. This adapter addresses the missing setup hook for rich theme/state/fixture/font cases. It does not bundle a browser or assume a version-specific Reflection API. A trusted project provider owns actual application activation, capture and comparison using the project's pinned browser/checker stack.
10
+
11
+ ```sh
12
+ penpair reflection plan --map design/implementation-cases.json
13
+ penpair reflection check --map design/implementation-cases.json --provider design/reflection-provider.mjs
14
+ ```
15
+
16
+ Mapping: `{"schemaVersion":1,"cases":[{"id":"dashboard-empty-dark","setup":"dark-empty"}]}`. A case must have current exact approved provenance, matching file pin and an explicit setup name. Missing mappings, legacy approvals and changed case metadata remain pending. No setup is silently replaced by the default route state. Baselines remain owned by the core; this adapter neither captures nor approves design references.
17
+
18
+ The provider module exports `async runCase(test)` and receives the immutable case ID, route, setup key, declared intent, baseline relative path/hash/scale and threshold. It must activate the route/theme/state/data fixture, verify actual browser state and fonts independently, capture a new PNG at the requested scale, and use the installed checker to compare it with the approved image. It returns:
19
+
20
+ ```json
21
+ {
22
+ "schemaVersion":1,"id":"dashboard-empty-dark","route":"/dashboard","setup":"dark-empty",
23
+ "observed":{"viewport":{"width":1280,"height":900},"theme":"dark","state":"empty","fixture":"no-items"},
24
+ "fonts":["Expected Font"],"fontsReady":true,"baselineHash":"EXACT_SHA256",
25
+ "screenshot":".penpair/comparison/dashboard.png","diffPixelRatio":0.01,
26
+ "provider":{"name":"PROJECT_PINNED_CHECKER","version":"ACTUAL_VERSION"}
27
+ }
28
+ ```
29
+
30
+ Observed context must come from browser readback, not copied test inputs. The adapter checks context/font evidence, independent screenshot path, PNG dimensions, byte hashes and unchanged baseline bytes. A trusted provider can lie: this interface is an evidence contract, not a sandbox or cryptographic attestation of browser work. Modules and transitive imports execute project code. Pixel ratios are checker evidence, never a visual verdict, accessibility assessment or owner approval. Exit 0 means complete evidence within thresholds; 2 means measured drift; 3 means missing/incomplete evidence. Every result still says `owner-review-required`.
31
+
32
+ No real consumer was modified and no live implementation route was run here. The offline integration test uses a named stub; replace it with a real provider and record the I12 human trial before claiming adoption. Release/publishing is the owner's act and all six packages remain in lockstep.
@@ -0,0 +1,2 @@
1
+ import type { BrowserCaptureMetadata, ImplementationCase } from "./types.js";
2
+ export declare function validateCaptureMetadata(capture: BrowserCaptureMetadata, implementationCase: ImplementationCase): void;
@@ -0,0 +1,65 @@
1
+ function hasValidGeometry(capture, implementationCase) {
2
+ const rootBox = capture.rootBox;
3
+ const clip = capture.clip;
4
+ if (!rootBox || !clip) {
5
+ return false;
6
+ }
7
+ if (![
8
+ rootBox.x,
9
+ rootBox.y,
10
+ rootBox.width,
11
+ rootBox.height,
12
+ clip.x,
13
+ clip.y,
14
+ clip.width,
15
+ clip.height,
16
+ ].every(Number.isFinite)) {
17
+ return false;
18
+ }
19
+ return (rootBox.x >= 0 &&
20
+ rootBox.y >= 0 &&
21
+ rootBox.width > 0 &&
22
+ rootBox.height > 0 &&
23
+ clip.x === Math.floor(rootBox.x) &&
24
+ clip.y === Math.floor(rootBox.y) &&
25
+ clip.width === Math.ceil(rootBox.x + rootBox.width) - clip.x &&
26
+ clip.height === Math.ceil(rootBox.y + rootBox.height) - clip.y &&
27
+ clip.width === implementationCase.intent.viewport.width &&
28
+ clip.height === implementationCase.intent.viewport.height);
29
+ }
30
+ function hasStableUnobstructedLayout(capture) {
31
+ const pointer = capture.pointer;
32
+ const scroll = capture.scroll;
33
+ const occlusion = capture.occlusion;
34
+ return (pointer?.outsideRoot === true &&
35
+ pointer.hoveredControls === 0 &&
36
+ Number.isFinite(pointer.x) &&
37
+ Number.isFinite(pointer.y) &&
38
+ Boolean(scroll) &&
39
+ [
40
+ scroll.x,
41
+ scroll.y,
42
+ ].every(Number.isFinite) &&
43
+ Number.isInteger(capture.layoutSamples) &&
44
+ capture.layoutSamples >= 3 &&
45
+ (!occlusion ||
46
+ (Number.isInteger(occlusion.samples) && occlusion.samples >= 1 && occlusion.blocked === 0)));
47
+ }
48
+ function hasLoadedFonts(capture, expectedFonts) {
49
+ return (Array.isArray(capture.fonts) &&
50
+ capture.fonts.every((font) => Boolean(font.family && font.weight && font.style && font.status === "loaded")) &&
51
+ expectedFonts.every((expectedFont) => capture.fonts.some((font) => font.family === expectedFont)));
52
+ }
53
+ export function validateCaptureMetadata(capture, implementationCase) {
54
+ if (capture.schemaVersion !== 1 ||
55
+ !capture.root ||
56
+ capture.rounding !== "outward-css-pixels" ||
57
+ capture.deviceScaleFactor !== implementationCase.baseline.scale ||
58
+ !capture.browser?.name ||
59
+ !capture.browser.version ||
60
+ !hasValidGeometry(capture, implementationCase) ||
61
+ !hasStableUnobstructedLayout(capture) ||
62
+ !hasLoadedFonts(capture, implementationCase.intent.fonts ?? [])) {
63
+ throw new Error("Invalid browser capture geometry, font or pointer evidence");
64
+ }
65
+ }
@@ -0,0 +1,3 @@
1
+ import { type BaselineResult, type BaselinesConfig } from "@penpair/core";
2
+ import type { CasePlan, ImplementationMapping } from "./types.js";
3
+ export declare function planImplementationCases(config: BaselinesConfig, results: BaselineResult[], mapping: ImplementationMapping): CasePlan;
@@ -0,0 +1,97 @@
1
+ import path from "node:path";
2
+ import { observationHash, validateBaselineCase, } from "@penpair/core";
3
+ function validateMapping(mapping) {
4
+ if (!mapping ||
5
+ mapping.schemaVersion !== 1 ||
6
+ !Array.isArray(mapping.cases) ||
7
+ mapping.cases.length > 1000) {
8
+ throw new Error("Unsupported implementation mapping");
9
+ }
10
+ const mappedIds = new Set();
11
+ for (const entry of mapping.cases) {
12
+ if (!entry ||
13
+ typeof entry.id !== "string" ||
14
+ !entry.id ||
15
+ mappedIds.has(entry.id) ||
16
+ typeof entry.setup !== "string" ||
17
+ !/^[a-zA-Z][\w.-]{0,100}$/.test(entry.setup)) {
18
+ throw new Error("Duplicate/invalid explicit setup mapping");
19
+ }
20
+ mappedIds.add(entry.id);
21
+ }
22
+ }
23
+ function matchingCaseApproval(result, intent) {
24
+ const approved = result?.approved && !("legacy" in result.approved) ? result.approved : undefined;
25
+ if (!result ||
26
+ !approved?.case ||
27
+ !result.captured ||
28
+ approved.approval?.kind === "forced-reference") {
29
+ return undefined;
30
+ }
31
+ if (![
32
+ "same",
33
+ "new",
34
+ "changed",
35
+ ].includes(result.status)) {
36
+ return undefined;
37
+ }
38
+ const matchesCase = observationHash(approved.case) === observationHash(intent);
39
+ const matchesCapture = approved.sha256 === result.captured.sha256 &&
40
+ approved.frameId === result.frameId &&
41
+ approved.fileId === result.captured.fileId &&
42
+ approved.pageId === result.captured.pageId &&
43
+ approved.revision === result.captured.revision &&
44
+ approved.file === result.file;
45
+ return matchesCase && matchesCapture ? approved : undefined;
46
+ }
47
+ export function planImplementationCases(config, results, mapping) {
48
+ validateMapping(mapping);
49
+ const cases = [];
50
+ const pending = [];
51
+ for (const frame of config.frames) {
52
+ if (!frame.route) {
53
+ continue;
54
+ }
55
+ const result = results.find((baselineResult) => baselineResult.id === frame.id);
56
+ const setup = mapping.cases.find((entry) => entry.id === frame.id)?.setup;
57
+ const approved = frame.case && result && setup ? matchingCaseApproval(result, frame.case) : undefined;
58
+ if (!frame.case || !result || !approved || !setup) {
59
+ pending.push({
60
+ id: frame.id,
61
+ reason: "Requires current exact approved case provenance and an explicit implementation setup; legacy cases keep the existing generated Reflection path",
62
+ });
63
+ continue;
64
+ }
65
+ validateBaselineCase(frame.case, (message) => {
66
+ throw new Error(message);
67
+ });
68
+ cases.push({
69
+ id: frame.id,
70
+ route: frame.route,
71
+ intent: structuredClone(frame.case),
72
+ setup,
73
+ baseline: {
74
+ path: path.join(config.root, result.file),
75
+ sha256: approved.sha256,
76
+ scale: approved.scale,
77
+ },
78
+ threshold: config.threshold ?? 0.05,
79
+ });
80
+ }
81
+ for (const entry of mapping.cases) {
82
+ if (!config.frames.some((frame) => frame.id === entry.id)) {
83
+ pending.push({
84
+ id: entry.id,
85
+ reason: "Mapping refers to no configured baseline",
86
+ });
87
+ }
88
+ }
89
+ return {
90
+ schemaVersion: 1,
91
+ kind: "implementation-case-plan",
92
+ cases,
93
+ pending,
94
+ complete: pending.length === 0 && cases.length > 0,
95
+ visualApproval: false,
96
+ };
97
+ }
@@ -0,0 +1,2 @@
1
+ import { type AdapterContext } from "@penpair/core";
2
+ export declare function runReflectionCommand(context: AdapterContext, commandArguments: string[]): Promise<number>;
@@ -0,0 +1,72 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { createJiti } from "jiti";
4
+ import { parseCommandArgs, readDesignJson, resolveProjectInput, } from "@penpair/core";
5
+ import { planImplementationCases } from "./case-plan.js";
6
+ import { runImplementationCases } from "./run-cases.js";
7
+ export async function runReflectionCommand(context, commandArguments) {
8
+ const [subcommand, ...subcommandArguments] = commandArguments;
9
+ const { values, positionals } = parseCommandArgs({
10
+ args: subcommandArguments,
11
+ allowPositionals: true,
12
+ options: {
13
+ map: {
14
+ type: "string",
15
+ },
16
+ provider: {
17
+ type: "string",
18
+ },
19
+ json: {
20
+ type: "boolean",
21
+ },
22
+ },
23
+ });
24
+ if (![
25
+ "plan",
26
+ "check",
27
+ ].includes(subcommand ?? "") ||
28
+ positionals.length ||
29
+ !values.map ||
30
+ (subcommand === "plan" && values.provider) ||
31
+ (subcommand === "check" && !values.provider)) {
32
+ throw new Error("Usage: reflection plan --map FILE | reflection check --map FILE --provider FILE");
33
+ }
34
+ const baselinesConfig = context.config.baselines;
35
+ if (!baselinesConfig) {
36
+ throw new Error("Configure baselines first");
37
+ }
38
+ const manifest = (await readDesignJson(context.root, path.join(baselinesConfig.root, "penpair-baselines.json")));
39
+ if (manifest.schemaVersion !== 2 ||
40
+ !Array.isArray(manifest.frames) ||
41
+ !context.config.file ||
42
+ manifest.fileId !== context.config.file.id) {
43
+ throw new Error("A schema-2 baseline manifest matching the configured file pin is required");
44
+ }
45
+ const plan = planImplementationCases(baselinesConfig, manifest.frames, (await readDesignJson(context.root, values.map, 1024 * 1024)));
46
+ let report = plan;
47
+ let exitCode = plan.complete ? 0 : 3;
48
+ if (subcommand === "check") {
49
+ const providerFile = await resolveProjectInput(context.root, values.provider);
50
+ const provider = (await createJiti(providerFile, {
51
+ moduleCache: false,
52
+ tryNative: false,
53
+ }).evalModule(await readFile(providerFile, "utf8"), {
54
+ filename: providerFile,
55
+ async: true,
56
+ forceTranspile: true,
57
+ }));
58
+ if (typeof provider.runCase !== "function") {
59
+ throw new Error("Trusted project provider must export async runCase(test)");
60
+ }
61
+ const result = await runImplementationCases(context.root, plan, provider);
62
+ report = result;
63
+ exitCode = !result.complete
64
+ ? 3
65
+ : result.cases.some((caseReport) => caseReport.withinThreshold === false)
66
+ ? 2
67
+ : 0;
68
+ }
69
+ context.output?.result(report);
70
+ (context.output?.log ?? console.log)(JSON.stringify(report, null, 2));
71
+ return exitCode;
72
+ }
@@ -0,0 +1,2 @@
1
+ import type { ImplementationCase, ProviderEvidence } from "./types.js";
2
+ export declare function validateProviderEvidence(evidence: ProviderEvidence, implementationCase: ImplementationCase): void;
@@ -0,0 +1,34 @@
1
+ import { observationHash } from "@penpair/core";
2
+ import { validateCaptureMetadata } from "./capture-validation.js";
3
+ export function validateProviderEvidence(evidence, implementationCase) {
4
+ const requestedObservation = {
5
+ viewport: implementationCase.intent.viewport,
6
+ theme: implementationCase.intent.theme,
7
+ state: implementationCase.intent.state,
8
+ fixture: implementationCase.intent.fixture,
9
+ };
10
+ if (!evidence ||
11
+ evidence.schemaVersion !== 1 ||
12
+ evidence.id !== implementationCase.id ||
13
+ evidence.route !== implementationCase.route ||
14
+ evidence.setup !== implementationCase.setup ||
15
+ observationHash(evidence.observed) !== observationHash(requestedObservation) ||
16
+ evidence.baselineHash !== implementationCase.baseline.sha256 ||
17
+ evidence.fontsReady !== true ||
18
+ !Array.isArray(evidence.fonts) ||
19
+ (implementationCase.intent.fonts ?? []).some((font) => !evidence.fonts.includes(font)) ||
20
+ !evidence.provider?.name ||
21
+ !evidence.provider.version ||
22
+ !Number.isFinite(evidence.diffPixelRatio) ||
23
+ evidence.diffPixelRatio < 0 ||
24
+ evidence.diffPixelRatio > 1) {
25
+ throw new Error("Provider did not independently observe the exact requested state, fixture, viewport, fonts and baseline");
26
+ }
27
+ if (evidence.provider.name === "penpair-browser-starter-rgba-exact" &&
28
+ (!evidence.capture || !evidence.capture.occlusion)) {
29
+ throw new Error("Browser starter requires complete capture metadata");
30
+ }
31
+ if (evidence.capture) {
32
+ validateCaptureMetadata(evidence.capture, implementationCase);
33
+ }
34
+ }
@@ -0,0 +1,14 @@
1
+ import { runReflectionCommand } from "./command.js";
2
+ export { planImplementationCases } from "./case-plan.js";
3
+ export { runImplementationCases } from "./run-cases.js";
4
+ export type { ImplementationMapping, ImplementationCase, CasePlan, BrowserCaptureMetadata, ProviderEvidence, ImplementationProvider, ImplementationCaseReport, ImplementationReport, } from "./types.js";
5
+ declare const _default: {
6
+ name: string;
7
+ commands: {
8
+ name: string;
9
+ summary: string;
10
+ usage: string[];
11
+ run: typeof runReflectionCommand;
12
+ }[];
13
+ };
14
+ export default _default;
package/dist/index.js ADDED
@@ -0,0 +1,18 @@
1
+ import { defineAdapter } from "@penpair/core";
2
+ import { runReflectionCommand } from "./command.js";
3
+ export { planImplementationCases } from "./case-plan.js";
4
+ export { runImplementationCases } from "./run-cases.js";
5
+ export default defineAdapter({
6
+ name: "@penpair/reflection-check",
7
+ commands: [
8
+ {
9
+ name: "reflection",
10
+ summary: "Plan explicit implementation cases and collect provider evidence",
11
+ usage: [
12
+ "penpair reflection plan --map FILE",
13
+ "penpair reflection check --map FILE --provider FILE",
14
+ ],
15
+ run: runReflectionCommand,
16
+ },
17
+ ],
18
+ });
@@ -0,0 +1,6 @@
1
+ export declare function hashPngBytes(bytes: Buffer): string;
2
+ export declare function readPngEvidence(root: string, file: string): Promise<{
3
+ bytes: Buffer;
4
+ width: number;
5
+ height: number;
6
+ }>;
@@ -0,0 +1,23 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import { resolveProjectInput } from "@penpair/core";
4
+ export function hashPngBytes(bytes) {
5
+ return createHash("sha256").update(bytes).digest("hex");
6
+ }
7
+ export async function readPngEvidence(root, file) {
8
+ const input = await resolveProjectInput(root, file);
9
+ if ((await stat(input)).size > 64 * 1024 * 1024) {
10
+ throw new Error("Image exceeds 64 MiB");
11
+ }
12
+ const bytes = await readFile(input);
13
+ if (bytes.length < 24 ||
14
+ bytes.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a" ||
15
+ bytes.subarray(12, 16).toString() !== "IHDR") {
16
+ throw new Error("Provider evidence must reference PNG bytes");
17
+ }
18
+ return {
19
+ bytes,
20
+ width: bytes.readUInt32BE(16),
21
+ height: bytes.readUInt32BE(20),
22
+ };
23
+ }
@@ -0,0 +1,2 @@
1
+ import type { CasePlan, ImplementationProvider, ImplementationReport } from "./types.js";
2
+ export declare function runImplementationCases(root: string, plan: CasePlan, provider: ImplementationProvider): Promise<ImplementationReport>;
@@ -0,0 +1,64 @@
1
+ import { resolveProjectInput } from "@penpair/core";
2
+ import { validateProviderEvidence } from "./evidence-validation.js";
3
+ import { hashPngBytes, readPngEvidence } from "./png-evidence.js";
4
+ async function collectCaseEvidence(root, implementationCase, provider) {
5
+ if (!(Number.isFinite(implementationCase.baseline.scale) && implementationCase.baseline.scale > 0)) {
6
+ throw new Error("Missing capture scale");
7
+ }
8
+ const baselineImage = await readPngEvidence(root, implementationCase.baseline.path);
9
+ if (hashPngBytes(baselineImage.bytes) !== implementationCase.baseline.sha256) {
10
+ throw new Error("Approved baseline bytes changed");
11
+ }
12
+ const evidence = await provider.runCase(structuredClone(implementationCase));
13
+ validateProviderEvidence(evidence, implementationCase);
14
+ const implementationImage = await readPngEvidence(root, evidence.screenshot);
15
+ const screenshotPath = await resolveProjectInput(root, evidence.screenshot);
16
+ const baselinePath = await resolveProjectInput(root, implementationCase.baseline.path);
17
+ if (screenshotPath === baselinePath) {
18
+ throw new Error("Screenshot must be independently captured, not the baseline path");
19
+ }
20
+ const viewport = implementationCase.intent.viewport;
21
+ const scale = implementationCase.baseline.scale;
22
+ if (implementationImage.width !== Math.round(viewport.width * scale) ||
23
+ implementationImage.height !== Math.round(viewport.height * scale) ||
24
+ baselineImage.width !== implementationImage.width ||
25
+ baselineImage.height !== implementationImage.height) {
26
+ throw new Error("Provider PNG scale/viewport differs from the design case");
27
+ }
28
+ const baselineAfterCapture = await readPngEvidence(root, implementationCase.baseline.path);
29
+ if (hashPngBytes(baselineAfterCapture.bytes) !== implementationCase.baseline.sha256) {
30
+ throw new Error("Baseline changed during provider execution");
31
+ }
32
+ return {
33
+ id: implementationCase.id,
34
+ complete: true,
35
+ withinThreshold: evidence.diffPixelRatio <= implementationCase.threshold,
36
+ evidence,
37
+ screenshotHash: hashPngBytes(implementationImage.bytes),
38
+ };
39
+ }
40
+ export async function runImplementationCases(root, plan, provider) {
41
+ const reports = [];
42
+ for (const implementationCase of plan.cases) {
43
+ try {
44
+ reports.push(await collectCaseEvidence(root, implementationCase, provider));
45
+ }
46
+ catch (error) {
47
+ reports.push({
48
+ id: implementationCase.id,
49
+ complete: false,
50
+ reason: String(error.message),
51
+ });
52
+ }
53
+ }
54
+ return {
55
+ schemaVersion: 1,
56
+ kind: "implementation-case-evidence",
57
+ complete: plan.complete && reports.length > 0 && reports.every((report) => report.complete),
58
+ cases: reports,
59
+ pending: plan.pending,
60
+ visualApproval: false,
61
+ verdict: "owner-review-required",
62
+ accessibility: "not-assessed",
63
+ };
64
+ }
@@ -0,0 +1,112 @@
1
+ import type { BaselineCase } from "@penpair/core";
2
+ export interface ImplementationMapping {
3
+ schemaVersion: 1;
4
+ cases: Array<{
5
+ id: string;
6
+ setup: string;
7
+ }>;
8
+ }
9
+ export interface ImplementationCase {
10
+ id: string;
11
+ route: string;
12
+ intent: BaselineCase;
13
+ setup: string;
14
+ baseline: {
15
+ path: string;
16
+ sha256: string;
17
+ scale: number;
18
+ };
19
+ threshold: number;
20
+ }
21
+ export interface CasePlan {
22
+ schemaVersion: 1;
23
+ kind: "implementation-case-plan";
24
+ cases: ImplementationCase[];
25
+ pending: Array<{
26
+ id: string;
27
+ reason: string;
28
+ }>;
29
+ complete: boolean;
30
+ visualApproval: false;
31
+ }
32
+ export interface BrowserCaptureMetadata {
33
+ schemaVersion: 1;
34
+ root: string;
35
+ rootBox: {
36
+ x: number;
37
+ y: number;
38
+ width: number;
39
+ height: number;
40
+ };
41
+ clip: {
42
+ x: number;
43
+ y: number;
44
+ width: number;
45
+ height: number;
46
+ };
47
+ rounding: "outward-css-pixels";
48
+ deviceScaleFactor: number;
49
+ browser: {
50
+ name: string;
51
+ version: string;
52
+ };
53
+ fonts: Array<{
54
+ family: string;
55
+ weight: string;
56
+ style: string;
57
+ status: "loaded";
58
+ }>;
59
+ pointer: {
60
+ x: number;
61
+ y: number;
62
+ outsideRoot: boolean;
63
+ hoveredControls: number;
64
+ };
65
+ occlusion?: {
66
+ samples: number;
67
+ blocked: number;
68
+ };
69
+ scroll: {
70
+ x: number;
71
+ y: number;
72
+ };
73
+ layoutSamples: number;
74
+ }
75
+ export interface ProviderEvidence {
76
+ schemaVersion: 1;
77
+ id: string;
78
+ route: string;
79
+ setup: string;
80
+ observed: Pick<BaselineCase, "viewport" | "theme" | "state" | "fixture">;
81
+ fonts: string[];
82
+ fontsReady: boolean;
83
+ baselineHash: string;
84
+ capture?: BrowserCaptureMetadata;
85
+ screenshot: string;
86
+ diffPixelRatio: number;
87
+ provider: {
88
+ name: string;
89
+ version: string;
90
+ };
91
+ }
92
+ export interface ImplementationProvider {
93
+ runCase(implementationCase: ImplementationCase): Promise<ProviderEvidence>;
94
+ }
95
+ export interface ImplementationCaseReport {
96
+ id: string;
97
+ complete: boolean;
98
+ withinThreshold?: boolean;
99
+ evidence?: ProviderEvidence;
100
+ screenshotHash?: string;
101
+ reason?: string;
102
+ }
103
+ export interface ImplementationReport {
104
+ schemaVersion: 1;
105
+ kind: "implementation-case-evidence";
106
+ complete: boolean;
107
+ cases: ImplementationCaseReport[];
108
+ pending: CasePlan["pending"];
109
+ visualApproval: false;
110
+ verdict: "owner-review-required";
111
+ accessibility: "not-assessed";
112
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "@penpair/reflection-check",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.3.0",
4
+ "description": "Explicit design case planning and verified implementation evidence for penpair baselines.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/niklas-westman/penpair.git",
9
+ "directory": "packages/reflection-check"
10
+ },
11
+ "homepage": "https://github.com/niklas-westman/penpair#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/niklas-westman/penpair/issues"
14
+ },
15
+ "type": "module",
16
+ "engines": {
17
+ "node": ">=22"
18
+ },
19
+ "main": "./dist/index.js",
20
+ "types": "./dist/index.d.ts",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "import": "./dist/index.js",
25
+ "default": "./dist/index.js"
26
+ },
27
+ "./package.json": "./package.json"
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "LICENSE",
32
+ "README.md"
33
+ ],
34
+ "keywords": [
35
+ "penpot",
36
+ "reflection",
37
+ "design-verification"
38
+ ],
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "dependencies": {
43
+ "jiti": "^2.4.2",
44
+ "@penpair/core": "0.3.0"
45
+ },
46
+ "scripts": {
47
+ "build": "tsc -p tsconfig.json",
48
+ "typecheck": "tsc -p tsconfig.json --noEmit",
49
+ "test": "vitest run"
50
+ }
6
51
  }