diff --git a/.docs/2026-07-19-public-evidence-product-slice-plan.md b/.docs/2026-07-19-public-evidence-product-slice-plan.md
new file mode 100644
index 0000000..a03094e
--- /dev/null
+++ b/.docs/2026-07-19-public-evidence-product-slice-plan.md
@@ -0,0 +1,26 @@
+# Public Evidence Product Slice Plan
+
+## Objective
+
+Use current public evidence from Reddit, X, and GitHub to select, build, validate, and ship one working OpenAPI painkiller before customer interviews.
+
+## Constraints
+
+- Build one complete vertical slice, not an editor platform.
+- Use public complaints, issue discussions, and active repositories as directional evidence. Do not treat engagement as willingness to pay.
+- Keep uploads local to the browser unless persistence is required by the selected workflow.
+- Do not add authentication, billing, workspaces, collaboration, or speculative infrastructure.
+- Preserve OpenAPI semantics and report uncertainty honestly.
+
+## Execution
+
+1. Collect recent pain signals from Reddit and X plus recurring issues and workflows from GitHub.
+2. Rank pains by frequency, urgency, buyer relevance, implementation cost, and fit with the current positioning.
+3. Write the selected feature specification, validation contract, and task ledger before implementation.
+4. Build the smallest end-to-end workflow on a feature branch.
+5. Test valid, invalid, breaking, non-breaking, no-JavaScript, mobile, privacy, and accessibility behavior as applicable.
+6. Deploy a preview, inspect it in a browser, open a pull request, pass CI, and ship only the validated slice.
+
+## Decision Rule
+
+Prefer a workflow that produces an immediate decision from two real OpenAPI documents. Reject ideas that require accounts, persistent customer data, third-party integrations, or broad editing before value is visible.
diff --git a/.docs/2026-07-19-public-openapi-pain-evidence.md b/.docs/2026-07-19-public-openapi-pain-evidence.md
new file mode 100644
index 0000000..37a8698
--- /dev/null
+++ b/.docs/2026-07-19-public-openapi-pain-evidence.md
@@ -0,0 +1,20 @@
+# Public OpenAPI Pain Evidence
+
+## Decision
+
+Ship a browser-local OpenAPI change check. A user selects a baseline and revised JSON or YAML contract, then receives a concise report of high-confidence breaking changes.
+
+## Evidence
+
+- A June 2026 FastAPI discussion describes accidental response-field changes becoming more likely as AI tooling increases development pace. The recommended control is comparing generated OpenAPI contracts in CI: https://www.reddit.com/r/FastAPI/comments/1tvfwxr/prevent_unintentional_breaking_api_changes_in/
+- A February 2026 discussion reports repeated review work caused by inconsistent OpenAPI documents and SDK generator failures: https://www.reddit.com/r/node/comments/1r76obi/i_was_tired_of_fixing_inconsistent_openapi_specs/
+- A January 2026 discussion repeatedly identifies synchronization between implementation and OpenAPI as the central requirement: https://www.reddit.com/r/node/comments/1q5imqi/openapi_static_or_dynamic/
+- GitHub documents removed operations, renamed parameters or response fields, newly required parameters, type changes, enum removals, validation changes, and authentication changes as consumer-breaking categories: https://docs.github.com/en/rest/about-the-rest-api/breaking-changes
+- The active oasdiff issue backlog shows that exhaustive semantic comparison is difficult, including OpenAPI 3.1 composition, multi-type schemas, formats, defaults, and semantic wording: https://github.com/oasdiff/oasdiff/issues
+- Current oasdiff documentation separates definite errors, potential warnings, and informational changes, supporting a severity-based decision surface: https://github.com/oasdiff/oasdiff/blob/main/docs/BREAKING-CHANGES.md
+
+X search produced weak direct pain evidence. It showed OpenAPI being used as agent tooling input, but did not produce a stronger buyer signal than Reddit and GitHub. Do not inflate that channel's contribution.
+
+## Scope Choice
+
+Start with high-confidence structural breaks that can be explained deterministically. Do not claim exhaustive compatibility analysis. Keep contracts in memory and avoid accounts, persistence, remote URLs, GitHub installation, or CI integration.
diff --git a/.tasks/2026-07-19-TSK-8-contract-change-check-TASKS.md b/.tasks/2026-07-19-TSK-8-contract-change-check-TASKS.md
new file mode 100644
index 0000000..e16281b
--- /dev/null
+++ b/.tasks/2026-07-19-TSK-8-contract-change-check-TASKS.md
@@ -0,0 +1,37 @@
+# TSK-8: Contract Change Check
+
+**Overall status:** IN_PROGRESS
+
+## TG-001: Evidence and contract
+
+- **Owner:** Main agent
+- **Status:** DONE
+- **Acceptance:** Public evidence, scope, privacy boundary, supported checks, limitations, and validation contract are recorded.
+- **Evidence:** `.docs/2026-07-19-public-openapi-pain-evidence.md` and the approved feature specification.
+
+## TG-002: Comparison core
+
+- **Owner:** Main agent
+- **Status:** DONE
+- **Dependencies:** TG-001
+- **Acceptance:** JSON and YAML parsing plus supported change classification pass focused unit tests without network access.
+- **Validation:** `bun run test`
+- **Evidence:** Seven test files and 18 unit tests pass, including focused parser and comparison coverage.
+
+## TG-003: Browser workflow
+
+- **Owner:** Main agent
+- **Status:** DONE
+- **Dependencies:** TG-002
+- **Acceptance:** `/compare` delivers local file selection, samples, decision-oriented results, privacy disclosure, responsive layout, and accessible states.
+- **Validation:** `bun run test:e2e`
+- **Evidence:** Six browser tests pass across the marketing and comparison routes, including accessibility, narrow viewport, malformed input, oversized input, and no-JavaScript coverage.
+
+## TG-004: Release validation
+
+- **Owner:** Main agent
+- **Status:** IN_PROGRESS
+- **Dependencies:** TG-003
+- **Acceptance:** Full checks, preview deployment, browser inspection, pull request, CI, and rollback record pass.
+- **Validation:** `bun run check`
+- **Evidence:** The local full gate passes. Preview version `c4b254a2-b80a-445c-baff-822e5cba705f` serves `/compare` with the new route and assets. Pull request and CI remain.
diff --git a/bun.lock b/bun.lock
index 673d9d4..9483f3a 100644
--- a/bun.lock
+++ b/bun.lock
@@ -11,6 +11,7 @@
"drizzle-orm": "0.45.2",
"solid-js": "1.9.14",
"valibot": "1.4.2",
+ "yaml": "2.9.0",
},
"devDependencies": {
"@axe-core/playwright": "4.12.1",
@@ -730,6 +731,8 @@
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
+ "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
+
"youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
diff --git a/package.json b/package.json
index 8150c0a..e4f7061 100644
--- a/package.json
+++ b/package.json
@@ -26,7 +26,8 @@
"@tanstack/solid-start": "1.168.30",
"drizzle-orm": "0.45.2",
"solid-js": "1.9.14",
- "valibot": "1.4.2"
+ "valibot": "1.4.2",
+ "yaml": "2.9.0"
},
"devDependencies": {
"@axe-core/playwright": "4.12.1",
diff --git a/specs/features/2026-07-19-contract-change-check/spec.md b/specs/features/2026-07-19-contract-change-check/spec.md
new file mode 100644
index 0000000..5b62e27
--- /dev/null
+++ b/specs/features/2026-07-19-contract-change-check/spec.md
@@ -0,0 +1,53 @@
+# Contract Change Check
+
+**Status:** APPROVED
+**Owner:** Main agent
+**Roadmap:** Public-evidence product slice before Phase 2
+
+## Outcome
+
+A visitor can compare two local OpenAPI 3.x JSON or YAML documents and understand whether the revision contains a supported high-confidence breaking change before sharing any contract with a server.
+
+## Requirements
+
+1. Provide a dedicated `/compare` route linked from the marketing page.
+2. Accept one baseline file and one revised file, each limited to 2 MB and `.json`, `.yaml`, or `.yml`.
+3. Parse files entirely in the browser. Do not transmit, persist, log, or analyze contract contents server-side.
+4. Require an OpenAPI 3.x root version and a paths object in both documents.
+5. Detect and explain these high-confidence changes:
+ - operation removed
+ - required request parameter added
+ - successful response removed
+ - schema or property type changed
+ - response property removed
+ - request property made required
+ - supported security requirement removed from an operation
+6. Group results into breaking changes and review notes, with method, path, location, and plain-language impact.
+7. Show a clear no-supported-breaking-changes result without claiming full compatibility.
+8. Provide sample contracts so the workflow is useful without user data.
+9. Work with keyboard navigation, mobile layouts, reduced motion, and without client-side JavaScript for the explanatory shell. Comparison requires JavaScript and must say so.
+
+## Non-Goals
+
+- Full OpenAPI validation or exhaustive compatibility analysis
+- External `$ref` retrieval or dereferencing
+- Swagger 2.0, AsyncAPI, callbacks, webhooks, links, discriminator analysis, or JSON Schema composition semantics
+- Accounts, saved comparisons, GitHub integration, CI configuration, billing, or AI-generated judgments
+
+## Privacy and Failure Behavior
+
+- File contents remain in browser memory and are cleared on reload.
+- Reject unsupported extensions, files over 2 MB, malformed JSON or YAML, missing OpenAPI 3.x versions, and missing paths with actionable messages.
+- Do not fetch external references. Report unresolved `$ref` coverage as a review note.
+- Never render specification text as HTML.
+
+## Validation
+
+- Unit tests cover every supported breaking-change category and direction.
+- Browser tests cover sample comparison, malformed input, privacy copy, keyboard use, mobile layout, accessibility, and the no-JavaScript shell.
+- The production bundle contains no server upload path for contract contents.
+- `bun run check` passes and the Cloudflare preview is inspected before merge.
+
+## Rollout
+
+Deploy to preview first. Roll back by reverting the route, feature slice, marketing link, and YAML dependency. No data migration is required.
diff --git a/src/core/openapi/compare.test.ts b/src/core/openapi/compare.test.ts
new file mode 100644
index 0000000..110cac1
--- /dev/null
+++ b/src/core/openapi/compare.test.ts
@@ -0,0 +1,109 @@
+import { describe, expect, it } from 'vitest'
+
+import { compareOpenApi } from './compare'
+
+const operation = {
+ responses: {
+ '200': {
+ description: 'OK',
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object',
+ properties: { id: { type: 'string' }, dueDate: { type: 'string' } },
+ },
+ },
+ },
+ },
+ },
+}
+
+function document(nextOperation: Record = operation) {
+ return { openapi: '3.1.0', paths: { '/invoices/{id}': { get: nextOperation } } }
+}
+
+describe('compareOpenApi', () => {
+ it('reports the supported high-confidence breaking changes', () => {
+ const revision = document({
+ parameters: [{ in: 'query', name: 'expand', required: true, schema: { type: 'string' } }],
+ security: [{ oauth: [] }],
+ requestBody: {
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object',
+ required: ['email'],
+ properties: { email: { type: 'string' } },
+ },
+ },
+ },
+ },
+ responses: {
+ '200': {
+ description: 'OK',
+ content: {
+ 'application/json': {
+ schema: { type: 'object', properties: { id: { type: 'number' } } },
+ },
+ },
+ },
+ },
+ })
+ const base = document({
+ ...operation,
+ security: [{ apiKey: [] }, { oauth: [] }],
+ requestBody: {
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object',
+ properties: { email: { type: 'string' } },
+ },
+ },
+ },
+ },
+ })
+
+ expect(compareOpenApi(base, revision).breaking.map(({ code }) => code)).toEqual([
+ 'required-parameter-added',
+ 'response-property-removed',
+ 'schema-type-changed',
+ 'request-property-required',
+ 'security-scheme-removed',
+ ])
+ })
+
+ it('reports removed operations and successful responses', () => {
+ expect(compareOpenApi(document(), { openapi: '3.1.0', paths: {} }).breaking[0]?.code).toBe(
+ 'operation-removed',
+ )
+ expect(
+ compareOpenApi(document(), document({ responses: { '404': { description: 'Missing' } } }))
+ .breaking[0]?.code,
+ ).toBe('success-response-removed')
+ })
+
+ it('reports removed response properties', () => {
+ const result = compareOpenApi(
+ document(),
+ document({
+ responses: {
+ '200': {
+ description: 'OK',
+ content: {
+ 'application/json': {
+ schema: { type: 'object', properties: { id: { type: 'string' } } },
+ },
+ },
+ },
+ },
+ }),
+ )
+
+ expect(result.breaking.map(({ code }) => code)).toContain('response-property-removed')
+ })
+
+ it('does not claim a breaking change for identical contracts', () => {
+ expect(compareOpenApi(document(), document())).toEqual({ breaking: [], review: [] })
+ })
+})
diff --git a/src/core/openapi/compare.ts b/src/core/openapi/compare.ts
new file mode 100644
index 0000000..d903c8d
--- /dev/null
+++ b/src/core/openapi/compare.ts
@@ -0,0 +1,352 @@
+export type ChangeSeverity = 'breaking' | 'review'
+
+export interface ContractChange {
+ severity: ChangeSeverity
+ code: string
+ method?: string
+ path?: string
+ location: string
+ message: string
+}
+
+export interface ComparisonResult {
+ breaking: ContractChange[]
+ review: ContractChange[]
+}
+
+type ObjectValue = Record
+
+const methods = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'] as const
+
+function object(value: unknown): ObjectValue | undefined {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+ ? (value as ObjectValue)
+ : undefined
+}
+
+function strings(value: unknown): string[] {
+ return Array.isArray(value)
+ ? value.filter((item): item is string => typeof item === 'string')
+ : []
+}
+
+function localRef(document: ObjectValue, value: unknown): ObjectValue | undefined {
+ const schema = object(value)
+ const ref = schema?.['$ref']
+ if (typeof ref !== 'string') return schema
+ if (!ref.startsWith('#/')) return undefined
+
+ let current: unknown = document
+ for (const segment of ref.slice(2).split('/')) {
+ current = object(current)?.[segment.replaceAll('~1', '/').replaceAll('~0', '~')]
+ }
+ return object(current)
+}
+
+function mediaSchemas(value: unknown): ObjectValue[] {
+ const content = object(object(value)?.['content'])
+ if (!content) return []
+ return Object.values(content)
+ .map((media) => object(media)?.['schema'])
+ .map(object)
+ .filter((schema): schema is ObjectValue => Boolean(schema))
+}
+
+function change(
+ severity: ChangeSeverity,
+ code: string,
+ message: string,
+ location: string,
+ path?: string,
+ method?: string,
+): ContractChange {
+ return {
+ severity,
+ code,
+ message,
+ location,
+ ...(path ? { path } : {}),
+ ...(method ? { method } : {}),
+ }
+}
+
+function compareSchema(
+ baseDocument: ObjectValue,
+ revisionDocument: ObjectValue,
+ baseValue: unknown,
+ revisionValue: unknown,
+ context: 'request' | 'response',
+ location: string,
+ path: string,
+ method: string,
+ changes: ContractChange[],
+ seen: Set,
+) {
+ const key = `${context}:${method}:${path}:${location}`
+ if (seen.has(key)) return
+ seen.add(key)
+
+ const base = localRef(baseDocument, baseValue)
+ const revision = localRef(revisionDocument, revisionValue)
+ if (!base || !revision) {
+ changes.push(
+ change(
+ 'review',
+ 'unresolved-ref',
+ 'This schema uses a reference outside the supported local comparison boundary.',
+ location,
+ path,
+ method,
+ ),
+ )
+ return
+ }
+
+ const baseType = base['type']
+ const revisionType = revision['type']
+ if (baseType !== undefined && revisionType !== undefined && baseType !== revisionType) {
+ changes.push(
+ change(
+ 'breaking',
+ 'schema-type-changed',
+ `Type changed from ${String(baseType)} to ${String(revisionType)}.`,
+ location,
+ path,
+ method,
+ ),
+ )
+ return
+ }
+
+ const baseProperties = object(base['properties']) ?? {}
+ const revisionProperties = object(revision['properties']) ?? {}
+ if (context === 'response') {
+ for (const property of Object.keys(baseProperties)) {
+ if (!(property in revisionProperties)) {
+ changes.push(
+ change(
+ 'breaking',
+ 'response-property-removed',
+ `Response property "${property}" was removed.`,
+ `${location}.properties.${property}`,
+ path,
+ method,
+ ),
+ )
+ }
+ }
+ } else {
+ const baseRequired = new Set(strings(base['required']))
+ for (const property of strings(revision['required'])) {
+ if (!baseRequired.has(property)) {
+ changes.push(
+ change(
+ 'breaking',
+ 'request-property-required',
+ `Request property "${property}" is now required.`,
+ `${location}.required`,
+ path,
+ method,
+ ),
+ )
+ }
+ }
+ }
+
+ for (const property of Object.keys(baseProperties)) {
+ if (property in revisionProperties) {
+ compareSchema(
+ baseDocument,
+ revisionDocument,
+ baseProperties[property],
+ revisionProperties[property],
+ context,
+ `${location}.properties.${property}`,
+ path,
+ method,
+ changes,
+ seen,
+ )
+ }
+ }
+
+ if (base['items'] && revision['items']) {
+ compareSchema(
+ baseDocument,
+ revisionDocument,
+ base['items'],
+ revision['items'],
+ context,
+ `${location}.items`,
+ path,
+ method,
+ changes,
+ seen,
+ )
+ }
+}
+
+function parameters(pathItem: ObjectValue, operation: ObjectValue): ObjectValue[] {
+ return [
+ ...(Array.isArray(pathItem['parameters']) ? pathItem['parameters'] : []),
+ ...(Array.isArray(operation['parameters']) ? operation['parameters'] : []),
+ ]
+ .map(object)
+ .filter((item): item is ObjectValue => Boolean(item))
+}
+
+function securityKeys(document: ObjectValue, operation: ObjectValue): Set {
+ const value = operation['security'] ?? document['security']
+ if (!Array.isArray(value)) return new Set()
+ return new Set(value.flatMap((requirement) => Object.keys(object(requirement) ?? {})))
+}
+
+function compareOperation(
+ baseDocument: ObjectValue,
+ revisionDocument: ObjectValue,
+ basePathItem: ObjectValue,
+ revisionPathItem: ObjectValue,
+ baseOperation: ObjectValue,
+ revisionOperation: ObjectValue,
+ path: string,
+ method: string,
+ changes: ContractChange[],
+) {
+ const baseParameters = new Map(
+ parameters(basePathItem, baseOperation).map((parameter) => [
+ `${String(parameter['in'])}:${String(parameter['name'])}`,
+ parameter,
+ ]),
+ )
+ for (const parameter of parameters(revisionPathItem, revisionOperation)) {
+ const key = `${String(parameter['in'])}:${String(parameter['name'])}`
+ const previous = baseParameters.get(key)
+ if (parameter['required'] === true && previous?.['required'] !== true) {
+ changes.push(
+ change(
+ 'breaking',
+ 'required-parameter-added',
+ `Required ${String(parameter['in'])} parameter "${String(parameter['name'])}" was added.`,
+ `paths.${path}.${method}.parameters`,
+ path,
+ method,
+ ),
+ )
+ }
+ }
+
+ const baseResponses = object(baseOperation['responses']) ?? {}
+ const revisionResponses = object(revisionOperation['responses']) ?? {}
+ for (const status of Object.keys(baseResponses).filter((code) => /^2\d\d$/.test(code))) {
+ if (!(status in revisionResponses)) {
+ changes.push(
+ change(
+ 'breaking',
+ 'success-response-removed',
+ `Successful response ${status} was removed.`,
+ `paths.${path}.${method}.responses.${status}`,
+ path,
+ method,
+ ),
+ )
+ continue
+ }
+ const baseSchemas = mediaSchemas(baseResponses[status])
+ const revisionSchemas = mediaSchemas(revisionResponses[status])
+ if (baseSchemas[0] && revisionSchemas[0]) {
+ compareSchema(
+ baseDocument,
+ revisionDocument,
+ baseSchemas[0],
+ revisionSchemas[0],
+ 'response',
+ `paths.${path}.${method}.responses.${status}`,
+ path,
+ method,
+ changes,
+ new Set(),
+ )
+ }
+ }
+
+ const baseRequestSchemas = mediaSchemas(baseOperation['requestBody'])
+ const revisionRequestSchemas = mediaSchemas(revisionOperation['requestBody'])
+ if (baseRequestSchemas[0] && revisionRequestSchemas[0]) {
+ compareSchema(
+ baseDocument,
+ revisionDocument,
+ baseRequestSchemas[0],
+ revisionRequestSchemas[0],
+ 'request',
+ `paths.${path}.${method}.requestBody`,
+ path,
+ method,
+ changes,
+ new Set(),
+ )
+ }
+
+ const revisionSecurity = securityKeys(revisionDocument, revisionOperation)
+ for (const scheme of securityKeys(baseDocument, baseOperation)) {
+ if (!revisionSecurity.has(scheme)) {
+ changes.push(
+ change(
+ 'breaking',
+ 'security-scheme-removed',
+ `Supported security scheme "${scheme}" was removed.`,
+ `paths.${path}.${method}.security`,
+ path,
+ method,
+ ),
+ )
+ }
+ }
+}
+
+export function compareOpenApi(
+ baseDocument: ObjectValue,
+ revisionDocument: ObjectValue,
+): ComparisonResult {
+ const changes: ContractChange[] = []
+ const basePaths = object(baseDocument['paths']) ?? {}
+ const revisionPaths = object(revisionDocument['paths']) ?? {}
+
+ for (const [path, basePathValue] of Object.entries(basePaths)) {
+ const basePathItem = object(basePathValue) ?? {}
+ const revisionPathItem = object(revisionPaths[path])
+ for (const method of methods) {
+ const baseOperation = object(basePathItem[method])
+ if (!baseOperation) continue
+ const revisionOperation = object(revisionPathItem?.[method])
+ if (!revisionOperation) {
+ changes.push(
+ change(
+ 'breaking',
+ 'operation-removed',
+ `${method.toUpperCase()} ${path} was removed.`,
+ `paths.${path}.${method}`,
+ path,
+ method,
+ ),
+ )
+ continue
+ }
+ compareOperation(
+ baseDocument,
+ revisionDocument,
+ basePathItem,
+ revisionPathItem ?? {},
+ baseOperation,
+ revisionOperation,
+ path,
+ method,
+ changes,
+ )
+ }
+ }
+
+ return {
+ breaking: changes.filter(({ severity }) => severity === 'breaking'),
+ review: changes.filter(({ severity }) => severity === 'review'),
+ }
+}
diff --git a/src/features/contract-change/contract-change-check.tsx b/src/features/contract-change/contract-change-check.tsx
new file mode 100644
index 0000000..50cbdba
--- /dev/null
+++ b/src/features/contract-change/contract-change-check.tsx
@@ -0,0 +1,215 @@
+import { For, Show, createSignal } from 'solid-js'
+
+import { compareOpenApi, type ComparisonResult } from '../../core/openapi/compare'
+import { ContractParseError, parseContract } from './parse-contract'
+
+const maxFileBytes = 2 * 1024 * 1024
+const allowedExtensions = ['.json', '.yaml', '.yml']
+
+const sampleBaseline = `openapi: 3.1.0
+info:
+ title: Billing API
+ version: 1.0.0
+paths:
+ /invoices/{invoiceId}:
+ get:
+ responses:
+ "200":
+ description: Invoice
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ invoiceId:
+ type: string
+ dueDate:
+ type: string
+`
+
+const sampleRevision = sampleBaseline.replace(
+ ` dueDate:
+ type: string`,
+ ` paymentDueAt:
+ type: string`,
+)
+
+interface SelectedContract {
+ name: string
+ source: string
+}
+
+function errorMessage(error: unknown): string {
+ return error instanceof ContractParseError || error instanceof Error
+ ? error.message
+ : 'The comparison could not be completed.'
+}
+
+async function readContract(file: File): Promise {
+ const lowerName = file.name.toLowerCase()
+ if (!allowedExtensions.some((extension) => lowerName.endsWith(extension))) {
+ throw new Error('Choose a .json, .yaml, or .yml file.')
+ }
+ if (file.size > maxFileBytes) throw new Error('Keep each contract under 2 MB.')
+ return { name: file.name, source: await file.text() }
+}
+
+export function ContractChangeCheck() {
+ const [baseline, setBaseline] = createSignal()
+ const [revision, setRevision] = createSignal()
+ const [result, setResult] = createSignal()
+ const [error, setError] = createSignal('')
+
+ async function selectFile(kind: 'baseline' | 'revision', file?: File) {
+ if (!file) return
+ setError('')
+ setResult()
+ try {
+ const selected = await readContract(file)
+ if (kind === 'baseline') setBaseline(selected)
+ else setRevision(selected)
+ } catch (cause) {
+ setError(errorMessage(cause))
+ }
+ }
+
+ function compare() {
+ const base = baseline()
+ const next = revision()
+ setError('')
+ if (!base || !next) {
+ setError('Choose both a baseline and a revised contract.')
+ return
+ }
+ try {
+ setResult(compareOpenApi(parseContract(base.source), parseContract(next.source)))
+ } catch (cause) {
+ setResult()
+ setError(errorMessage(cause))
+ }
+ }
+
+ function loadSample() {
+ setBaseline({ name: 'billing-api-baseline.yaml', source: sampleBaseline })
+ setRevision({ name: 'billing-api-revision.yaml', source: sampleRevision })
+ setError('')
+ setResult()
+ }
+
+ return (
+
+
+
+
+ Public product preview
+ Check a contract change before your users do.
+
+ Compare two OpenAPI 3.x files for a focused set of high-confidence breaking changes. Your
+ files stay in this browser and disappear on reload.
+
+
+
+
+
+
+
Local comparison
+
Baseline against revision
+
+
+ Load breaking-change sample
+
+
+
+
+
+ 01
+ Baseline contract
+ {baseline()?.name ?? 'Choose the current contract'}
+ void selectFile('baseline', event.currentTarget.files?.[0])}
+ />
+
+
+ 02
+ Revised contract
+ {revision()?.name ?? 'Choose the proposed contract'}
+ void selectFile('revision', event.currentTarget.files?.[0])}
+ />
+
+
+
+
+
+ Check contract impact
+
+
OpenAPI 3.x · JSON or YAML · 2 MB per file · no upload
+
+
+ Comparison requires JavaScript to read both files locally.
+ {(message) => {message()}
}
+
+
+ {(comparison) => (
+
+
+
+
Decision
+
+ {comparison().breaking.length > 0
+ ? 'Breaking change detected.'
+ : 'No supported breaking changes detected.'}
+
+
+
{comparison().breaking.length}
+
+
+ 0}>
+
+
+ {(item) => (
+
+
+ {item.method?.toUpperCase()}
+ {item.path}
+
+ {item.message}
+ {item.location}
+
+ )}
+
+
+
+
+ 0}>
+
+
Review notes
+
{(item) => {item.message}
}
+
+
+
+
+ This preview checks a focused subset of structural changes. It does not prove full
+ behavioral compatibility or resolve external references.
+
+
+ )}
+
+
+
+ )
+}
diff --git a/src/features/contract-change/parse-contract.test.ts b/src/features/contract-change/parse-contract.test.ts
new file mode 100644
index 0000000..a911567
--- /dev/null
+++ b/src/features/contract-change/parse-contract.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from 'vitest'
+
+import { ContractParseError, parseContract } from './parse-contract'
+
+describe('parseContract', () => {
+ it('parses JSON and YAML OpenAPI 3 documents', () => {
+ expect(parseContract('{"openapi":"3.1.0","paths":{}}')['openapi']).toBe('3.1.0')
+ expect(parseContract('openapi: 3.0.4\npaths: {}')['openapi']).toBe('3.0.4')
+ })
+
+ it('rejects malformed and unsupported documents', () => {
+ expect(() => parseContract('openapi: [')).toThrow(ContractParseError)
+ expect(() => parseContract('swagger: "2.0"\npaths: {}')).toThrow('OpenAPI 3.x')
+ expect(() => parseContract('openapi: 3.1.0')).toThrow('paths object')
+ })
+})
diff --git a/src/features/contract-change/parse-contract.ts b/src/features/contract-change/parse-contract.ts
new file mode 100644
index 0000000..572f494
--- /dev/null
+++ b/src/features/contract-change/parse-contract.ts
@@ -0,0 +1,31 @@
+import { parseDocument } from 'yaml'
+
+export type OpenApiDocument = Record
+
+export class ContractParseError extends Error {}
+
+export function parseContract(source: string): OpenApiDocument {
+ const parsed = parseDocument(source, { logLevel: 'error' })
+ if (parsed.errors.length > 0) {
+ throw new ContractParseError(
+ parsed.errors[0]?.message ?? 'The contract is not valid JSON or YAML.',
+ )
+ }
+
+ const value: unknown = parsed.toJS({ maxAliasCount: 50 })
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
+ throw new ContractParseError('The contract root must be an object.')
+ }
+ const document = value as OpenApiDocument
+ if (typeof document['openapi'] !== 'string' || !/^3\.\d+\.\d+/.test(document['openapi'])) {
+ throw new ContractParseError('Use an OpenAPI 3.x document with an openapi version field.')
+ }
+ if (
+ typeof document['paths'] !== 'object' ||
+ document['paths'] === null ||
+ Array.isArray(document['paths'])
+ ) {
+ throw new ContractParseError('The contract must contain a paths object.')
+ }
+ return document
+}
diff --git a/src/features/marketing/marketing-home.tsx b/src/features/marketing/marketing-home.tsx
index 2b640aa..9d0599f 100644
--- a/src/features/marketing/marketing-home.tsx
+++ b/src/features/marketing/marketing-home.tsx
@@ -42,12 +42,8 @@ export function MarketingHome() {
Evidence
- recordConversion('design_partner_clicked')}
- >
- Join research
+
+ Try comparison
@@ -59,31 +55,26 @@ export function MarketingHome() {
Catch the breaking change before your users do.
- We are testing whether small API teams need a clearer way to review OpenAPI contract
- changes before we build the product workflow.
+ Compare a baseline and revised OpenAPI contract for high-confidence consumer breaks.
+ Files stay in your browser.
- Interviews are free. A product pilot and $29/workspace/month price are hypotheses.
- They are not available yet.
+ Free public preview. No account, upload, persistence, or claim of exhaustive analysis.
-
Illustrative product concept · not a live editor
+
Product workflow · live comparison available
billing-api.yaml PR #184
@@ -175,12 +166,14 @@ export function MarketingHome() {
- OpenAPI Studio is in demand validation, not a working editor. Today’s repository
- contains this website, delivery checks, preview infrastructure, and identifier-free
- conversion measurement. OpenAPI 3.0/3.1 import, validation, diff review, and
- publishing are roadmap work. They are not implemented yet. No invented customers. No
+ OpenAPI Studio now includes a browser-local comparison preview for a focused set of
+ high-confidence OpenAPI changes. Exhaustive validation, editing, saved projects,
+ publishing, collaboration, and billing are not implemented. No invented customers. No
fake screenshots. No checkout.
+
+ Try the local comparison ↗
+
recordConversion('repository_clicked')}>
Follow the public build ↗
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
index 4776246..f2c6050 100644
--- a/src/routeTree.gen.ts
+++ b/src/routeTree.gen.ts
@@ -10,33 +10,43 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
+import { Route as CompareRouteImport } from './routes/compare'
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
+const CompareRoute = CompareRouteImport.update({
+ id: '/compare',
+ path: '/compare',
+ getParentRoute: () => rootRouteImport,
+} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
+ '/compare': typeof CompareRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
+ '/compare': typeof CompareRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
+ '/compare': typeof CompareRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
- fullPaths: '/'
+ fullPaths: '/' | '/compare'
fileRoutesByTo: FileRoutesByTo
- to: '/'
- id: '__root__' | '/'
+ to: '/' | '/compare'
+ id: '__root__' | '/' | '/compare'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
+ CompareRoute: typeof CompareRoute
}
declare module '@tanstack/solid-router' {
@@ -48,11 +58,19 @@ declare module '@tanstack/solid-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
+ '/compare': {
+ id: '/compare'
+ path: '/compare'
+ fullPath: '/compare'
+ preLoaderRoute: typeof CompareRouteImport
+ parentRoute: typeof rootRouteImport
+ }
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
+ CompareRoute: CompareRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
diff --git a/src/routes/compare.tsx b/src/routes/compare.tsx
new file mode 100644
index 0000000..f8e32a7
--- /dev/null
+++ b/src/routes/compare.tsx
@@ -0,0 +1,16 @@
+import { createFileRoute } from '@tanstack/solid-router'
+
+import { ContractChangeCheck } from '../features/contract-change/contract-change-check'
+
+export const Route = createFileRoute('/compare')({
+ component: ContractChangeCheck,
+ head: () => ({
+ meta: [
+ { title: 'OpenAPI change check | OpenAPI Studio' },
+ {
+ name: 'description',
+ content: 'Compare two local OpenAPI contracts for high-confidence breaking changes.',
+ },
+ ],
+ }),
+})
diff --git a/src/styles.css b/src/styles.css
index bb0cb06..7c715dd 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -146,6 +146,11 @@ a:focus-visible {
.site-footer a:focus-visible {
outline-color: #cf2f1b;
}
+button:focus-visible,
+input:focus-visible {
+ outline: 3px solid #cf2f1b;
+ outline-offset: 4px;
+}
.hero {
display: grid;
@@ -154,6 +159,204 @@ a:focus-visible {
align-items: center;
padding-block: clamp(2.5rem, 5vw, 4.5rem) clamp(4rem, 7vw, 6rem);
}
+
+.compare-page {
+ min-height: 100vh;
+ padding-bottom: 6rem;
+ background: #f5f5f3;
+}
+.compare-header {
+ display: flex;
+ min-height: 5rem;
+ align-items: center;
+ justify-content: space-between;
+ border-bottom: 1px solid #d4d0ca;
+}
+.compare-mark {
+ display: grid;
+ width: 2rem;
+ height: 2rem;
+ place-items: center;
+ color: #fff;
+ background: #cf2f1b;
+ font-size: 0.62rem;
+ letter-spacing: -0.04em;
+}
+.compare-intro {
+ display: grid;
+ gap: 1.25rem;
+ padding-block: clamp(3rem, 7vw, 7rem);
+}
+.compare-intro h1 {
+ max-width: 14ch;
+}
+.compare-intro > p:last-child {
+ max-width: 48rem;
+ font-size: clamp(1rem, 1.4vw, 1.25rem);
+ line-height: 1.65;
+}
+.compare-workspace {
+ min-width: 0;
+ padding: clamp(1.5rem, 3vw, 3rem);
+ border: 1px solid #bdb8b1;
+ background: #fff;
+}
+.compare-workspace-head,
+.result-summary {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 2rem;
+}
+.compare-workspace h2 {
+ margin-top: 0.5rem;
+ font-size: clamp(1.7rem, 3vw, 2.7rem);
+ letter-spacing: -0.035em;
+}
+.text-button {
+ padding: 0;
+ border: 0;
+ border-bottom: 1px solid currentColor;
+ color: #171310;
+ background: transparent;
+ font: inherit;
+ font-size: 0.78rem;
+ font-weight: 700;
+ cursor: pointer;
+}
+.file-grid {
+ min-width: 0;
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 1rem;
+ margin-top: 2.5rem;
+}
+.file-card {
+ display: grid;
+ min-width: 0;
+ min-height: 15rem;
+ align-content: start;
+ gap: 0.8rem;
+ padding: 1.5rem;
+ border: 1px solid #171310;
+ cursor: pointer;
+}
+.file-card:hover {
+ background: #f2f0ec;
+}
+.file-number {
+ margin-bottom: 1.5rem;
+ color: #cf2f1b;
+ font-size: 2rem;
+ font-weight: 750;
+}
+.file-card strong {
+ font-size: 1.1rem;
+}
+.file-card > span:not(.file-number) {
+ min-height: 2.5rem;
+ color: #655f59;
+ font-size: 0.8rem;
+ line-height: 1.5;
+ overflow-wrap: anywhere;
+}
+.file-card input {
+ width: 100%;
+ min-width: 0;
+ max-width: 100%;
+ margin-top: auto;
+ font: inherit;
+ font-size: 0.76rem;
+}
+.compare-action {
+ display: flex;
+ align-items: center;
+ gap: 1.5rem;
+ margin-top: 1.5rem;
+}
+.compare-action button {
+ cursor: pointer;
+}
+.compare-action p,
+.js-note,
+.coverage-note {
+ color: #655f59;
+ font-size: 0.75rem;
+ line-height: 1.6;
+}
+.js-note {
+ margin-top: 0.75rem;
+}
+.error-banner {
+ margin-top: 1.5rem;
+ padding: 1rem 1.25rem;
+ border-left: 4px solid #cf2f1b;
+ background: #f5e5e2;
+ font-size: 0.85rem;
+ line-height: 1.5;
+}
+.results {
+ margin-top: 3rem;
+ padding-top: 2.5rem;
+ border-top: 3px solid #171310;
+}
+.result-count {
+ color: #cf2f1b;
+ font-size: clamp(4rem, 9vw, 8rem);
+ line-height: 0.8;
+}
+.change-list {
+ display: grid;
+ gap: 1px;
+ margin: 2.5rem 0 0;
+ padding: 0;
+ background: #bdb8b1;
+ list-style: none;
+}
+.change-list li {
+ display: grid;
+ gap: 0.8rem;
+ padding: 1.4rem;
+ background: #f5f5f3;
+}
+.change-list li > code {
+ color: #655f59;
+ font-size: 0.7rem;
+ overflow-wrap: anywhere;
+}
+.change-context {
+ display: flex;
+ align-items: center;
+ gap: 0.8rem;
+}
+.change-context span {
+ padding: 0.25rem 0.4rem;
+ color: #fff;
+ background: #171310;
+ font-size: 0.68rem;
+ font-weight: 750;
+}
+.review-notes {
+ margin-top: 1.5rem;
+ padding: 1.25rem;
+ border: 1px solid #bdb8b1;
+}
+.review-notes p,
+.coverage-note {
+ margin-top: 0.75rem;
+}
+
+@media (max-width: 720px) {
+ .compare-workspace-head,
+ .result-summary,
+ .compare-action {
+ align-items: stretch;
+ flex-direction: column;
+ }
+ .file-grid {
+ grid-template-columns: 1fr;
+ }
+}
.hero-copy {
display: flex;
flex-direction: column;
diff --git a/tests/browser/compare.test.ts b/tests/browser/compare.test.ts
new file mode 100644
index 0000000..c3f2d2c
--- /dev/null
+++ b/tests/browser/compare.test.ts
@@ -0,0 +1,62 @@
+import AxeBuilder from '@axe-core/playwright'
+import { expect, test } from '@playwright/test'
+
+test('turns two local sample contracts into a breaking-change decision', async ({ page }) => {
+ await page.goto('/compare')
+
+ await expect(
+ page.getByRole('heading', { level: 1, name: 'Check a contract change before your users do.' }),
+ ).toBeVisible()
+ await expect(page.getByText('Your files stay in this browser')).toBeVisible()
+
+ await page.getByRole('button', { name: 'Load breaking-change sample' }).click()
+ await page.getByRole('button', { name: 'Check contract impact' }).click()
+
+ await expect(page.getByRole('heading', { name: 'Breaking change detected.' })).toBeVisible()
+ await expect(page.getByText('Response property "dueDate" was removed.')).toBeVisible()
+ await expect(page.getByText('This preview checks a focused subset')).toBeVisible()
+ expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([])
+})
+
+test('rejects malformed and oversized contract inputs', async ({ page }) => {
+ await page.goto('/compare')
+ const inputs = page.locator('input[type="file"]')
+
+ await inputs.nth(0).setInputFiles({
+ name: 'baseline.yaml',
+ mimeType: 'text/yaml',
+ buffer: Buffer.from('openapi: ['),
+ })
+ await inputs.nth(1).setInputFiles({
+ name: 'revision.yaml',
+ mimeType: 'text/yaml',
+ buffer: Buffer.from('openapi: 3.1.0\npaths: {}'),
+ })
+ await page.getByRole('button', { name: 'Check contract impact' }).click()
+ await expect(page.getByText(/Unexpected flow-seq-end|Flow sequence/)).toBeVisible()
+
+ await inputs.nth(0).setInputFiles({
+ name: 'too-large.yaml',
+ mimeType: 'text/yaml',
+ buffer: Buffer.alloc(2 * 1024 * 1024 + 1),
+ })
+ await expect(page.getByText('Keep each contract under 2 MB.')).toBeVisible()
+})
+
+test('keeps the explanatory shell usable on mobile without JavaScript', async ({ browser }) => {
+ const context = await browser.newContext({
+ javaScriptEnabled: false,
+ viewport: { width: 360, height: 800 },
+ })
+ const page = await context.newPage()
+
+ await page.goto('/compare')
+
+ await expect(page.getByRole('heading', { name: 'Baseline against revision' })).toBeVisible()
+ await expect(
+ page.getByText('Comparison requires JavaScript to read both files locally.'),
+ ).toBeVisible()
+ expect((await page.locator('body').evaluate((body) => body.scrollWidth)) <= 360).toBe(true)
+
+ await context.close()
+})
diff --git a/tests/browser/marketing.test.ts b/tests/browser/marketing.test.ts
index 890643e..db8c4c6 100644
--- a/tests/browser/marketing.test.ts
+++ b/tests/browser/marketing.test.ts
@@ -28,7 +28,7 @@ test('presents a truthful design-partner conversion path', async ({ page }) => {
}),
).toBeVisible()
- const application = page.getByRole('link', { name: 'Email the founder about the research' })
+ const application = page.getByRole('link', { name: 'Apply by email' })
const applicationUrl = new URL((await application.getAttribute('href')) ?? '')
expect(applicationUrl.protocol).toBe('mailto:')
expect(applicationUrl.pathname).toBe('pitechae@gmail.com')
@@ -41,8 +41,8 @@ test('presents a truthful design-partner conversion path', async ({ page }) => {
expect(applicationUrl.searchParams.get('body')).toContain(
'Would we pay $29/workspace/month if this worked? Why or why not?',
)
- await expect(page.getByText('not a working editor')).toBeVisible()
- await expect(page.getByText('roadmap work. They are not implemented yet')).toBeVisible()
+ await expect(page.getByText('browser-local comparison preview')).toBeVisible()
+ await expect(page.getByText('saved projects, publishing')).toBeVisible()
await expect(page.getByText('Illustrative prototype. This is not a screenshot')).toBeVisible()
await expect(page.getByText('Do not upload a contract or confidential material')).toBeVisible()
const renderedContent = await page
@@ -71,7 +71,7 @@ test('has no detectable accessibility violations and a usable tab order', async
await page.keyboard.press('Tab')
await expect(page.getByRole('link', { name: 'Evidence' })).toBeFocused()
await page.keyboard.press('Tab')
- await expect(page.getByRole('link', { name: 'Join research' })).toBeFocused()
+ await expect(page.getByRole('link', { name: 'Try comparison' })).toBeFocused()
})
test('keeps the offer usable on a narrow screen without JavaScript', async ({ browser }) => {
@@ -84,9 +84,7 @@ test('keeps the offer usable on a narrow screen without JavaScript', async ({ br
await page.goto('/')
await expect(page.getByRole('main')).toBeVisible()
- await expect(
- page.getByRole('link', { name: 'Email the founder about the research' }),
- ).toBeVisible()
+ await expect(page.getByRole('link', { name: 'Check a contract change' })).toBeVisible()
await expect(page.getByText('price hypothesis')).toBeVisible()
expect((await page.locator('body').evaluate((body) => body.scrollWidth)) <= 360).toBe(true)