diff --git a/src/guidance/context.ts b/src/guidance/context.ts index 9e9e57c..f149d89 100644 --- a/src/guidance/context.ts +++ b/src/guidance/context.ts @@ -3,6 +3,7 @@ import type { GuidanceContext } from "./types"; export type InspiredesignGuidanceQualitySource = { rankedReferenceCount: number; + rankedReferenceUrls?: string[]; rejectedReferenceCount: number; topReferenceScore?: number; topReferenceConfidence?: number; @@ -24,6 +25,7 @@ export type InspiredesignGuidanceSource = { acceptedUrls: string[]; failure?: string; failures: number; + hardFailureReasonCodes?: string[]; }; metrics: { referenceCount: number; @@ -38,7 +40,55 @@ export type InspiredesignGuidanceSource = { }; }; +const HARD_PROVIDER_FAILURE_REASON_CODES = new Set([ + "auth_required", + "challenge_detected", + "policy_blocked", + "rate_limited", + "token_required" +]); + +const normalizeComparableUrl = (value: string): string | null => { + try { + const url = new URL(value); + url.protocol = "https:"; + url.hostname = url.hostname.toLowerCase().replace(/^www\./, ""); + url.hash = ""; + url.search = ""; + return url.toString(); + } catch { + return null; + } +}; + +const hasAcceptedUserSuppliedSiteRecipeReferenceUrl = (source: InspiredesignGuidanceSource): boolean => { + if (source.metrics.referenceCount === 0 || source.quality.rankedReferenceCount === 0) return false; + const rankedReferenceUrls = new Set((source.quality.rankedReferenceUrls ?? []) + .map(normalizeComparableUrl) + .filter((url): url is string => typeof url === "string")); + if (rankedReferenceUrls.size === 0) return false; + const requestedSiteRecipeIds = new Set(source.requestedProviders + .map((providerId) => resolveSiteRecipeForProvider(providerId)?.id) + .filter((recipeId): recipeId is string => typeof recipeId === "string")); + return (source.urls ?? []).some((url) => { + const recipeId = resolveSiteRecipeForUrl(url)?.id; + if (!recipeId) return false; + if (requestedSiteRecipeIds.size > 0 && !requestedSiteRecipeIds.has(recipeId)) return false; + const normalizedUrl = normalizeComparableUrl(url); + return normalizedUrl !== null && rankedReferenceUrls.has(normalizedUrl); + }); +}; + +const hasHardProviderFailureSignal = (source: InspiredesignGuidanceSource): boolean => ( + ( + !hasAcceptedUserSuppliedSiteRecipeReferenceUrl(source) + && (source.discovery.hardFailureReasonCodes ?? []).some((reasonCode) => HARD_PROVIDER_FAILURE_REASON_CODES.has(reasonCode)) + ) + || (source.primaryConstraint?.reasonCode ? HARD_PROVIDER_FAILURE_REASON_CODES.has(source.primaryConstraint.reasonCode) : false) +); + const hasProviderUnavailableSignal = (source: InspiredesignGuidanceSource): boolean => { + if (hasHardProviderFailureSignal(source)) return true; if (source.metrics.referenceCount > 0 && source.quality.rankedReferenceCount > 0) return false; if (source.discovery.requested && source.discovery.acceptedUrls.length === 0 && source.discovery.failures > 0) return true; if (source.discovery.failure && source.discovery.acceptedUrls.length === 0) return true; @@ -111,6 +161,7 @@ export const createInspiredesignGuidanceContext = ( details: { brief: source.brief, discoveryFailure: source.discovery.failure ?? "", + hardFailureReasonCodes: source.discovery.hardFailureReasonCodes ?? [], primaryConstraintSummary: source.primaryConstraint?.summary ?? "" } }; diff --git a/src/guidance/recipes/pinterest.ts b/src/guidance/recipes/pinterest.ts index cf2f55f..d41e6ed 100644 --- a/src/guidance/recipes/pinterest.ts +++ b/src/guidance/recipes/pinterest.ts @@ -50,7 +50,24 @@ const pinterestGuidance: NextStepGuidance = { ] }; -const RESERVED_PINTEREST_BOARD_PATHS = new Set(["about", "business", "ideas", "login", "pin", "search", "settings", "today"]); +const PINTEREST_PIN_ID_PATTERN = /^\d+$/; +const RESERVED_PINTEREST_BOARD_PATHS = new Set([ + "about", + "board", + "business", + "create", + "explore", + "ideas", + "login", + "messages", + "notifications", + "pin", + "search", + "settings", + "shopping", + "today" +]); +const RESERVED_PINTEREST_IDEA_PATHS = new Set(["create", "edit", "search"]); const RESERVED_PINTEREST_PROFILE_TABS = new Set([ "activity", "comments", @@ -63,18 +80,35 @@ const RESERVED_PINTEREST_PROFILE_TABS = new Set([ "tried" ]); -const normalizePinterestCandidateUrl = (value: string): string | null => { +export const isAllowedPinterestReferenceHost = (hostname: string): boolean => ( + hostname === "pinterest.com" + || hostname === "www.pinterest.com" + || /^[a-z]{2}\.pinterest\.com$/.test(hostname) +); + +export const normalizePinterestReferenceUrl = (value: string): string | null => { const trimmed = value.trim(); const absolute = trimmed.startsWith("/") ? `https://www.pinterest.com${trimmed}` : trimmed; try { const url = new URL(absolute); + if (url.protocol !== "https:" && url.protocol !== "http:") return null; + url.protocol = "https:"; const hostname = url.hostname.toLowerCase(); - if (hostname !== "pinterest.com" && !hostname.endsWith(".pinterest.com")) return null; + if (!isAllowedPinterestReferenceHost(hostname)) return null; const pathSegments = url.pathname.split("/").filter(Boolean); - const isPin = pathSegments[0] === "pin" && pathSegments.length === 2; - const isIdea = pathSegments[0] === "ideas" && pathSegments.length >= 2; + const isPin = ( + pathSegments[0] === "pin" + && pathSegments.length === 2 + && PINTEREST_PIN_ID_PATTERN.test(pathSegments[1] ?? "") + ); + const isIdea = ( + pathSegments[0] === "ideas" + && pathSegments.length >= 3 + && !RESERVED_PINTEREST_IDEA_PATHS.has(pathSegments[1] ?? "") + && PINTEREST_PIN_ID_PATTERN.test(pathSegments[pathSegments.length - 1] ?? "") + ); const isBoard = pathSegments.length === 2 && !RESERVED_PINTEREST_BOARD_PATHS.has(pathSegments[0] ?? "") && !RESERVED_PINTEREST_PROFILE_TABS.has(pathSegments[1] ?? "") @@ -91,7 +125,7 @@ const normalizePinterestCandidateUrl = (value: string): string | null => { const extractPinterestUrlsFromText = (value: string): string[] => { const candidates = value.match(/(?:https?:\/\/(?:(?:www|[a-z]{2})\.)?pinterest\.com\/(?:pin\/[a-zA-Z0-9_-]+|ideas\/[a-zA-Z0-9/_-]+|[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+)|(? url !== null); }; @@ -102,7 +136,8 @@ const buildPinterestSearchUrl = (query: string): string => { const extractPinterestReferenceUrls = (candidate: SiteRecipeReferenceCandidate): string[] => { return [ - normalizePinterestCandidateUrl(candidate.url ?? ""), + normalizePinterestReferenceUrl(candidate.url ?? ""), + ...(candidate.links ?? []).map(normalizePinterestReferenceUrl), ...extractPinterestUrlsFromText(candidate.content ?? ""), ...extractPinterestUrlsFromText(candidate.html ?? "") ].filter((url): url is string => url !== null); diff --git a/src/guidance/recipes/site-registry.ts b/src/guidance/recipes/site-registry.ts index c2fefb3..0986dd0 100644 --- a/src/guidance/recipes/site-registry.ts +++ b/src/guidance/recipes/site-registry.ts @@ -1,4 +1,4 @@ -import { pinterestSiteRecipe } from "./pinterest"; +import { isAllowedPinterestReferenceHost, pinterestSiteRecipe } from "./pinterest"; import type { SiteRecipe } from "../types"; const freezeRecipeValue = (value: T): T => { @@ -22,14 +22,18 @@ export const resolveSiteRecipeForProvider = (providerId: string): SiteRecipe | u export const resolveSiteRecipeForUrl = (url: string): SiteRecipe | undefined => { let hostname: string; try { - hostname = normalizeHostname(new URL(url).hostname); + hostname = new URL(url).hostname.toLowerCase(); } catch { return undefined; } return SITE_RECIPES.find((recipe) => { + if (recipe.id === "social/pinterest") { + return isAllowedPinterestReferenceHost(hostname); + } + const normalizedHost = normalizeHostname(hostname); return recipe.hostnames.some((candidate) => { const normalized = normalizeHostname(candidate); - return hostname === normalized || hostname.endsWith(`.${normalized}`); + return normalizedHost === normalized || normalizedHost.endsWith(`.${normalized}`); }); }); }; diff --git a/src/guidance/types.ts b/src/guidance/types.ts index f7a57e2..55f2392 100644 --- a/src/guidance/types.ts +++ b/src/guidance/types.ts @@ -153,6 +153,7 @@ export type SiteRecipeReferenceCandidate = { url?: string; content?: string; html?: string; + links?: string[]; }; export type SiteRecipeBrowserNativeDiscovery = { diff --git a/src/inspiredesign/contract.ts b/src/inspiredesign/contract.ts index dad4072..45aeff7 100644 --- a/src/inspiredesign/contract.ts +++ b/src/inspiredesign/contract.ts @@ -30,8 +30,10 @@ import { } from "./brief-expansion"; import { buildInspiredesignDesignVectors, + buildInspiredesignDesignReferencePatternBoard, buildInspiredesignReferencePatternBoard, getInspiredesignReferenceSignals, + isInspiredesignDesignReference, hasInspiredesignUsableReferenceEvidence, type InspiredesignDesignVectors, type InspiredesignReferencePatternBoard @@ -469,6 +471,7 @@ export type BuildInspiredesignPacketInput = { urls: string[]; references: InspiredesignReferenceEvidence[]; includePrototypeGuidance?: boolean; + referenceEvidenceRequired?: boolean; }; const BASE_CONTRACT_TEMPLATE: DesignContractTemplate = designContractTemplateJson; @@ -738,13 +741,16 @@ const renderReferenceFirstAdvancedBrief = ( vectors: InspiredesignDesignVectors, references: InspiredesignReferenceEvidence[] ): string => { - if (board.references.length === 0) { + if (board.references.length === 0 || vectors.sourcePriority !== "reference-evidence-first") { if (references.length > 0) { return [ "Reference evidence unavailable:", - "URL references were attempted, but no usable creative evidence was captured. Treat this as a capture gap, not a design direction.", + "URL references were attempted, but no ready-quality creative evidence was captured. Treat this as a capture or intent gap, not a design direction.", "", - formatBulletList(references.map((reference) => renderUnavailableReference(reference))), + formatBulletList([ + `${references.length} attempted reference(s) are retained in diagnostic artifacts only.`, + "Do not use rejected or not-ready reference URLs, names, screenshots, or metadata as creative direction." + ]), "", briefExpansion.advancedBrief ].join("\n"); @@ -838,11 +844,6 @@ const renderEvidenceDerivedAdvancedBrief = ( formatBulletList(format.bestFor) ].join("\n"); -const renderUnavailableReference = (reference: InspiredesignReferenceEvidence): string => { - const reason = reference.fetchFailure ?? reference.captureFailure ?? "no usable creative evidence captured"; - return `${reference.url}: fetch=${reference.fetchStatus}, capture=${reference.captureStatus}, reason=${clipText(reason, 160)}`; -}; - const cloneTemplate = (value: T): T => structuredClone(value); const referenceFingerprint = (value: string): string => { @@ -1605,23 +1606,21 @@ const buildFollowthrough = ({ type BuildDesignContractInput = { brief: string; - urls: string[]; - references: InspiredesignReferenceEvidence[]; + designReferences: InspiredesignReferenceEvidence[]; plan: InspiredesignGenerationPlan; format: InspiredesignBriefFormat; }; const buildDesignContract = ({ brief, - urls, - references, + designReferences, plan, format }: BuildDesignContractInput): CanvasDesignGovernance => ({ - intent: buildIntentBlock(brief, urls, references, format), + intent: buildIntentBlock(brief, designReferences.map((reference) => reference.url), designReferences, format), generationPlan: toCanvasGenerationPlan(plan), designLanguage: buildDesignLanguageBlock(plan.visualDirection.profile, format), - contentModel: buildContentModelBlock(brief, references.filter(hasInspiredesignUsableReferenceEvidence)), + contentModel: buildContentModelBlock(brief, designReferences), layoutSystem: buildLayoutSystemBlock(plan, format), typographySystem: buildTypographySystemBlock(format), colorSystem: buildColorSystemBlock(plan.visualDirection.profile, format), @@ -1696,6 +1695,7 @@ type BuildImplementationPlanInput = { profile: CanvasVisualDirectionProfile; format: InspiredesignBriefFormat; references: InspiredesignReferenceEvidence[]; + attemptedReferenceCount: number; synthesis: InspiredesignReferenceSynthesis; designVectors: InspiredesignDesignVectors; }; @@ -1704,6 +1704,7 @@ const buildImplementationPlan = ({ profile, format, references, + attemptedReferenceCount, synthesis, designVectors }: BuildImplementationPlanInput): InspiredesignImplementationPlan => ({ @@ -1743,7 +1744,7 @@ const buildImplementationPlan = ({ "Avoid horizontal scrolling for primary content." ], risksAndAmbiguities: [ - references.length === 0 + attemptedReferenceCount === 0 ? "No live references were supplied, so visual cues are derived entirely from the written brief." : synthesis.lines.length > 0 ? "Live references were reduced into reusable patterns; unique brand assets should still be recreated, not copied." @@ -2032,11 +2033,13 @@ const renderPrototypeGuidance = ( ].join("\n"); }; -const renderDeliverablesSummary = (includePrototypeGuidance: boolean): string => { +const renderDeliverablesSummary = (includePrototypeGuidance: boolean, canvasContinuationReady: boolean): string => { const deliverables = [ "Structured `designContract` JSON aligned to canvas governance", "Valid `generationPlan` JSON aligned to the canvas generation plan contract", - "Ready-to-fill `canvasPlanRequest` JSON for `canvas.plan.set`", + canvasContinuationReady + ? "Ready-to-fill `canvasPlanRequest` JSON for `canvas.plan.set`" + : "Diagnostic `canvasPlanRequest` preview; do not submit to Canvas until next-step guidance is ready", "Design-agent handoff JSON with contract scope, skill nudges, and richer implementation context", "Human-readable `design.md` design specification", "Engineering implementation plan in JSON and Markdown" @@ -2155,20 +2158,27 @@ export const buildInspiredesignPacket = (input: BuildInspiredesignPacketInput): title: reference.title ? trimText(reference.title) : undefined, excerpt: reference.excerpt ? trimText(reference.excerpt) : undefined })); - const usableReferences = references.filter(hasInspiredesignUsableReferenceEvidence); - const synthesis = buildReferenceSynthesis(usableReferences); + const referenceEvidenceRequired = input.referenceEvidenceRequired ?? (urls.length > 0 || references.length > 0); const referencePatternBoard = buildInspiredesignReferencePatternBoard( referenceFingerprint(brief), selectedFormat, references, brief ); + const readyReferenceIds = new Set( + referencePatternBoard.references.filter(isInspiredesignDesignReference).map((reference) => reference.id) + ); + const usableReferences = references + .filter(hasInspiredesignUsableReferenceEvidence) + .filter((reference) => readyReferenceIds.has(reference.id)); + const synthesis = buildReferenceSynthesis(usableReferences); const designVectors = buildInspiredesignDesignVectors(selectedFormat, referencePatternBoard); + const designReferencePatternBoard = buildInspiredesignDesignReferencePatternBoard(referencePatternBoard, designVectors); const effectiveFormat = buildEvidenceDerivedFormat(selectedFormat, designVectors); const targetAnalysis = buildTargetAnalysis( brief, effectiveFormat, - references, + usableReferences, synthesis, designVectors ); @@ -2187,7 +2197,7 @@ export const buildInspiredesignPacket = (input: BuildInspiredesignPacketInput): }); const advancedBriefMarkdown = renderReferenceFirstAdvancedBrief( effectiveBriefExpansion, - referencePatternBoard, + designReferencePatternBoard, designVectors, references ); @@ -2195,7 +2205,7 @@ export const buildInspiredesignPacket = (input: BuildInspiredesignPacketInput): brief, format: effectiveFormat, synthesis, - referencePatternBoard, + referencePatternBoard: designReferencePatternBoard, designVectors, targetAnalysis }); @@ -2203,8 +2213,7 @@ export const buildInspiredesignPacket = (input: BuildInspiredesignPacketInput): const canvasPlanRequest = buildCanvasPlanRequest(brief, generationPlan); const designContract = buildDesignContract({ brief, - urls, - references, + designReferences: usableReferences, plan: generationPlan, format: effectiveFormat }); @@ -2213,14 +2222,15 @@ export const buildInspiredesignPacket = (input: BuildInspiredesignPacketInput): briefExpansion: effectiveBriefExpansion, synthesis, includePrototypeGuidance, - referencePatternBoard, + referencePatternBoard: designReferencePatternBoard, designVectors, targetAnalysis }); const implementationPlan = buildImplementationPlan({ profile, format: effectiveFormat, - references, + references: usableReferences, + attemptedReferenceCount: references.length, synthesis, designVectors }); @@ -2253,8 +2263,8 @@ export const buildInspiredesignPacket = (input: BuildInspiredesignPacketInput): "", "## 3.2 Reference Pattern Board", "", - formatBulletList(referencePatternBoard.synthesis.sharedStrengths.length > 0 - ? referencePatternBoard.synthesis.sharedStrengths + formatBulletList(designVectors.patternsToBorrow.length > 0 + ? designVectors.patternsToBorrow : ["No live reference cues were captured."]), "", "## 3.3 Design Vectors", @@ -2294,7 +2304,10 @@ export const buildInspiredesignPacket = (input: BuildInspiredesignPacketInput): "", "# 7. Deliverables Summary", "", - renderDeliverablesSummary(Boolean(input.includePrototypeGuidance)) + renderDeliverablesSummary( + Boolean(input.includePrototypeGuidance), + !referenceEvidenceRequired || designReferencePatternBoard.references.length > 0 + ) ].join("\n"); const visualEvidence = buildVisualEvidencePayload(references); @@ -2311,7 +2324,7 @@ export const buildInspiredesignPacket = (input: BuildInspiredesignPacketInput): prototypeGuidanceMarkdown, visualEvidence, screenshotIndex, - rankedReferences: referencePatternBoard.references, + rankedReferences: designReferencePatternBoard.references, metaPromptMarkdown, evidence: buildEvidencePayload({ brief, @@ -2319,7 +2332,7 @@ export const buildInspiredesignPacket = (input: BuildInspiredesignPacketInput): advancedBriefMarkdown, urls, references, - referencePatternBoard, + referencePatternBoard: designReferencePatternBoard, designVectors, targetAnalysis }) diff --git a/src/inspiredesign/handoff.ts b/src/inspiredesign/handoff.ts index 2233f93..540509a 100644 --- a/src/inspiredesign/handoff.ts +++ b/src/inspiredesign/handoff.ts @@ -144,9 +144,9 @@ export const INSPIREDESIGN_ARTIFACT_GUIDE: InspiredesignArtifactGuide = { }, [INSPIREDESIGN_HANDOFF_FILES.rankedReferences]: { purpose: "Deterministic ranked reference pattern board for design transfer.", - expectedContents: ["rank", "score", "confidence", "visual strengths", "visual risks", "rejected references"], + expectedContents: ["rank", "score", "confidence", "visual strengths", "visual risks", "aggregate rejected counts"], howToUse: ["Start from rank 1 for dominant direction", "borrow patterns and reject risks explicitly"], - mustNot: ["Do not copy source brands or override the ranked order with source order"] + mustNot: ["Do not copy source brands, rejected reference URLs, or override the ranked order with source order"] }, [INSPIREDESIGN_HANDOFF_FILES.metaPrompt]: { purpose: "Markdown prompt for downstream design generation from harvested evidence.", diff --git a/src/inspiredesign/meta-prompt.ts b/src/inspiredesign/meta-prompt.ts index c8fa831..ab1a88c 100644 --- a/src/inspiredesign/meta-prompt.ts +++ b/src/inspiredesign/meta-prompt.ts @@ -1,9 +1,12 @@ import type { InspiredesignBriefExpansion } from "./brief-expansion"; -import type { - InspiredesignDesignVectors, - InspiredesignReferencePatternBoard +import { + isInspiredesignDesignReference, + type InspiredesignDesignVectors, + type InspiredesignReferencePatternBoard } from "./reference-pattern-board"; +type RankedReference = InspiredesignReferencePatternBoard["references"][number]; + const formatList = (items: readonly string[]): string => ( items.length > 0 ? items.map((item) => `- ${item}`).join("\n") @@ -11,12 +14,12 @@ const formatList = (items: readonly string[]): string => ( ); const formatRankedReferences = ( - board: InspiredesignReferencePatternBoard + references: readonly RankedReference[] ): string => { - if (board.references.length === 0) { - return "- No usable references were ranked. Work from the brief and note missing evidence."; + if (references.length === 0) { + return "- No ready references were ranked. Work from the brief and note missing evidence."; } - return board.references.map((reference) => [ + return references.map((reference) => [ `### Rank ${reference.rank}: ${reference.name}`, `- URL: ${reference.url}`, `- Score: ${reference.score}`, @@ -30,14 +33,18 @@ const formatRankedReferences = ( }; const formatRejectedReferences = ( - board: InspiredesignReferencePatternBoard + board: InspiredesignReferencePatternBoard, + notReadyReferences: readonly RankedReference[] ): string => { - if (board.rejectedReferences.length === 0) { - return "- No references were rejected from the creative synthesis."; - } - return board.rejectedReferences.map((reference) => ( - `- ${reference.url}: ${reference.reason} (fetch=${reference.fetchStatus}, capture=${reference.captureStatus})` - )).join("\n"); + const lines = [ + ...(board.rejectedReferences.length > 0 + ? [`- ${board.rejectedReferences.length} reference(s) were rejected as diagnostic-only or unavailable.`] + : []), + ...(notReadyReferences.length > 0 + ? [`- ${notReadyReferences.length} ranked reference(s) were not ready for creative synthesis due to score, confidence, or brief-intent mismatch.`] + : []) + ]; + return lines.length > 0 ? lines.join("\n") : "- No references were rejected from the creative synthesis."; }; export const buildInspiredesignMetaPrompt = (input: { @@ -45,55 +52,61 @@ export const buildInspiredesignMetaPrompt = (input: { briefExpansion: InspiredesignBriefExpansion; referencePatternBoard: InspiredesignReferencePatternBoard; designVectors: InspiredesignDesignVectors; -}): string => [ - "# InspireDesign Meta Prompt", - "", - "Use this prompt to generate a fresh design direction from evidence without copying any reference brand, asset, layout, or proprietary expression.", - "", - "## Source Brief", - input.brief, - "", - "## Prompt Format", - `- Format: ${input.briefExpansion.format.label}`, - `- Target surface: ${input.referencePatternBoard.targetSurface}`, - `- Dominant direction: ${input.referencePatternBoard.synthesis.dominantDirection}`, - "", - "## Ranked References", - formatRankedReferences(input.referencePatternBoard), - "", - "## Rejected References", - formatRejectedReferences(input.referencePatternBoard), - "", - "## Borrow Guidance", - formatList(input.designVectors.patternsToBorrow), - "", - "## Reject Guidance", - formatList([ - ...input.designVectors.patternsToReject, - "Do not copy logos, screenshots, protected brand assets, page structure, copy, or trade dress from references." - ]), - "", - "## Motion Posture", - formatList([ - ...input.designVectors.motionPosture, - ...input.designVectors.interactionMoments, - ...input.designVectors.advancedMotionAdvisory - ]), - "", - "## Accessibility Constraints", - formatList([ - "Keyboard navigation must reach every interactive element.", - "Focus states must be visible in every theme and viewport.", - "Respect prefers-reduced-motion with a static hierarchy-preserving alternative.", - "Validate contrast for text, controls, overlays, and disabled states." - ]), - "", - "## Validation Gates", - formatList([ - "Read visual-evidence.json, screenshot-index.json, ranked-references.json, and evidence.json before implementation.", - "Confirm screenshot paths exist before making visual claims.", - "Verify desktop and mobile layouts with real browser screenshots.", - "Run reduced-motion, keyboard, focus, and contrast checks before shipping.", - "Keep production code generation outside the harvest output." - ]) -].join("\n"); +}): string => { + const designReferences = input.referencePatternBoard.references.filter(isInspiredesignDesignReference); + const notReadyReferences = input.referencePatternBoard.references.filter( + (reference) => !isInspiredesignDesignReference(reference) + ); + return [ + "# InspireDesign Meta Prompt", + "", + "Use this prompt to generate a fresh design direction from evidence without copying any reference brand, asset, layout, or proprietary expression.", + "", + "## Source Brief", + input.brief, + "", + "## Prompt Format", + `- Format: ${input.briefExpansion.format.label}`, + `- Target surface: ${input.designVectors.surfaceIntent}`, + `- Dominant direction: ${input.designVectors.directionLabel}`, + "", + "## Ranked References", + formatRankedReferences(designReferences), + "", + "## Rejected References", + formatRejectedReferences(input.referencePatternBoard, notReadyReferences), + "", + "## Borrow Guidance", + formatList(input.designVectors.patternsToBorrow), + "", + "## Reject Guidance", + formatList([ + ...input.designVectors.patternsToReject, + "Do not copy logos, screenshots, protected brand assets, page structure, copy, or trade dress from references." + ]), + "", + "## Motion Posture", + formatList([ + ...input.designVectors.motionPosture, + ...input.designVectors.interactionMoments, + ...input.designVectors.advancedMotionAdvisory + ]), + "", + "## Accessibility Constraints", + formatList([ + "Keyboard navigation must reach every interactive element.", + "Focus states must be visible in every theme and viewport.", + "Respect prefers-reduced-motion with a static hierarchy-preserving alternative.", + "Validate contrast for text, controls, overlays, and disabled states." + ]), + "", + "## Validation Gates", + formatList([ + "Read visual-evidence.json, screenshot-index.json, ranked-references.json, and evidence.json before implementation.", + "Confirm screenshot paths exist before making visual claims.", + "Verify desktop and mobile layouts with real browser screenshots.", + "Run reduced-motion, keyboard, focus, and contrast checks before shipping.", + "Keep production code generation outside the harvest output." + ]) + ].join("\n"); +}; diff --git a/src/inspiredesign/reference-pattern-board.ts b/src/inspiredesign/reference-pattern-board.ts index fac2869..0835cfb 100644 --- a/src/inspiredesign/reference-pattern-board.ts +++ b/src/inspiredesign/reference-pattern-board.ts @@ -1,3 +1,4 @@ +import { isAllowedPinterestReferenceHost, normalizePinterestReferenceUrl } from "../guidance/recipes/pinterest"; import type { InspiredesignBriefFormat } from "./brief-expansion"; type ReferenceStatus = "captured" | "failed" | "skipped"; @@ -117,7 +118,10 @@ const SCORE_DOM = 8; const SCORE_PUBLIC_LANDING = 6; const SCORE_SIGNAL_CAP = 12; const SCORE_INTENT_MISMATCH_PENALTY = 55; +const SCORE_PINTEREST_CHROME_METADATA_PENALTY = 35; const MAX_REFERENCE_SCORE = 100; +const MIN_READY_REFERENCE_SCORE = 50; +const MIN_READY_REFERENCE_CONFIDENCE = 0.5; const ADVANCED_MOTION_FIELDS = [ "Advisory shader-style gradients: specify effect type, uniforms, static fallback, and reduced-motion replacement as design language only.", "Advisory WebGL-style depth cues: describe layered depth, camera-like parallax, and spatial hierarchy without requiring WebGL runtime.", @@ -319,21 +323,27 @@ const isDiagnosticPageText = (value: string): boolean => { const isInterfaceChromeText = (value: string): boolean => { const lower = value.toLowerCase(); + const actionRefCount = countActionRefs(value); + const markerCount = INTERFACE_CHROME_TEXT_MARKERS.filter((marker) => lower.includes(marker)).length; if ( lower === "your profile" || lower === "adobe, inc." || lower === "dribbble: the community for graphic design" || /^https?:\/\/\S+$/.test(lower) || (lower.includes("when autocomplete results are available") && lower.includes("touch device users")) + || (actionRefCount >= 3 && markerCount >= 2) || (lower.includes("get 20%") && lower.includes("dribbble: the community for graphic design")) || (lower.includes("our free wordpress themes are downloaded") && lower.includes("get them now")) ) { return true; } - const markerCount = INTERFACE_CHROME_TEXT_MARKERS.filter((marker) => lower.includes(marker)).length; return markerCount >= 3 || (lower.includes("pin card") && lower.includes("your profile")); }; +const countActionRefs = (value: string): number => ( + (value.match(/\[r\d+\]\s+(?:link|button|combobox|textbox|option)\s+/gi) ?? []).length +); + const hasPublicLandingSignal = (value: string): boolean => { const lower = value.toLowerCase(); const strongCount = PUBLIC_LANDING_TEXT_MARKERS.filter((marker) => lower.includes(marker)).length; @@ -383,6 +393,48 @@ const hasUsableRecoveredCreativeEvidence = (reference: ReferenceInput): boolean || hasCleanSignal(textFromHtml(reference.capture?.dom?.outerHTML)) ); +const isPinterestVisualReferenceUrl = (value: string): boolean => normalizePinterestReferenceUrl(value) !== null; + +const pinterestHostnameFromUrl = (value: string): string | null => { + try { + return new URL(value).hostname.toLowerCase(); + } catch { + return null; + } +}; + +const isPinterestOwnedReferenceUrl = (value: string): boolean => { + const hostname = pinterestHostnameFromUrl(value); + return hostname !== null && (hostname === "pinterest.com" || hostname.endsWith(".pinterest.com")); +}; + +const isUnapprovedPinterestReferenceUrl = (value: string): boolean => { + const hostname = pinterestHostnameFromUrl(value); + return hostname !== null && isPinterestOwnedReferenceUrl(value) && !isAllowedPinterestReferenceHost(hostname); +}; + +const hasCleanMetadataValue = (value: string | undefined): boolean => ( + typeof value === "string" && countActionRefs(value) < 3 && hasCleanSignal(value) +); + +const hasCleanMetadataSignal = (reference: ReferenceInput): boolean => ( + hasCleanMetadataValue(reference.title) + || hasCleanMetadataValue(reference.excerpt) + || hasCleanMetadataValue(reference.capture?.title) +); + +const hasOnlySoftPinterestChromeDiagnostics = (reasons: string[]): boolean => ( + reasons.every((reason) => reason === "interface_chrome_shell") +); + +const hasPinterestVisualMetadataEvidence = (reference: ReferenceInput, diagnosticReasons: string[]): boolean => ( + isPinterestVisualReferenceUrl(reference.url) + && reference.captureStatus === "captured" + && reference.capture?.visual?.status === "captured" + && hasCleanMetadataSignal(reference) + && hasOnlySoftPinterestChromeDiagnostics(diagnosticReasons) +); + const hasUsableCaptureEvidence = (reference: ReferenceInput): boolean => ( hasCleanSignal(reference.capture?.snapshot?.content) || hasUsableCloneCreativeEvidence(reference) @@ -409,7 +461,9 @@ const hasBlockingDiagnosticReason = (reasons: string[]): boolean => ( ); export const hasInspiredesignUsableReferenceEvidence = (reference: ReferenceInput): boolean => { + if (isUnapprovedPinterestReferenceUrl(reference.url)) return false; const diagnosticReasons = referenceDiagnosticReasons(reference); + if (hasPinterestVisualMetadataEvidence(reference, diagnosticReasons)) return true; if (hasBlockingDiagnosticReason(diagnosticReasons)) return false; if (diagnosticReasons.includes("login_or_challenge_state") && !hasUsableRecoveredCreativeEvidence(reference)) { return false; @@ -504,13 +558,18 @@ const appendSourceDetail = (patterns: string[], primarySignal: string): string[] const deriveCapturedVia = (reference: ReferenceInput): string[] => { const methods: string[] = []; + const diagnosticReasons = referenceDiagnosticReasons(reference); + const hasPinterestVisualMetadata = hasPinterestVisualMetadataEvidence(reference, diagnosticReasons); if (reference.fetchStatus === "captured") methods.push("fetch"); if (hasCleanSignal(reference.capture?.snapshot?.content)) methods.push("snapshot"); if (hasUsableCloneCreativeEvidence(reference)) { methods.push("clone"); } if (hasCleanSignal(textFromHtml(reference.capture?.dom?.outerHTML))) methods.push("dom"); - if (reference.capture?.visual?.status === "captured" && hasUsableCaptureEvidence(reference)) methods.push("visual"); + if ( + reference.capture?.visual?.status === "captured" + && (hasUsableCaptureEvidence(reference) || hasPinterestVisualMetadata) + ) methods.push("visual"); return methods; }; @@ -520,14 +579,24 @@ const scoreReference = ( isPublicLanding: boolean ): number => { let score = 0; + const diagnosticReasons = referenceDiagnosticReasons(reference); + const hasPinterestVisualMetadata = hasPinterestVisualMetadataEvidence(reference, diagnosticReasons); if (reference.fetchStatus === "captured") score += SCORE_FETCH_CAPTURED; - if (reference.captureStatus === "captured" && hasUsableCaptureEvidence(reference)) score += SCORE_CAPTURE_CAPTURED; - if (reference.capture?.visual?.status === "captured" && hasUsableCaptureEvidence(reference)) score += SCORE_VISUAL_CAPTURED; + if (reference.captureStatus === "captured" && (hasUsableCaptureEvidence(reference) || hasPinterestVisualMetadata)) { + score += SCORE_CAPTURE_CAPTURED; + } + if ( + reference.capture?.visual?.status === "captured" + && (hasUsableCaptureEvidence(reference) || hasPinterestVisualMetadata) + ) score += SCORE_VISUAL_CAPTURED; if (hasCleanSignal(reference.capture?.snapshot?.content)) score += SCORE_SNAPSHOT; if (hasUsableCloneCreativeEvidence(reference)) score += SCORE_CLONE; if (hasCleanSignal(textFromHtml(reference.capture?.dom?.outerHTML))) score += SCORE_DOM; if (isPublicLanding) score += SCORE_PUBLIC_LANDING; score += Math.min(SCORE_SIGNAL_CAP, signals.length * 2); + if (hasPinterestVisualMetadata && diagnosticReasons.includes("interface_chrome_shell")) { + score -= SCORE_PINTEREST_CHROME_METADATA_PENALTY; + } return Math.min(MAX_REFERENCE_SCORE, score); }; @@ -743,6 +812,9 @@ const sortReferenceEntries = ( })); const rejectionReasonForReference = (reference: ReferenceInput): string => { + if (isUnapprovedPinterestReferenceUrl(reference.url)) { + return "Pinterest reference host is not approved for creative synthesis."; + } const diagnosticReasons = referenceDiagnosticReasons(reference); if (diagnosticReasons.length > 0) { return `Reference evidence is diagnostic-only: ${diagnosticReasons.join(", ")}.`; @@ -802,6 +874,61 @@ export const summarizeInspiredesignReferenceQuality = ( board: InspiredesignReferencePatternBoard ): InspiredesignReferenceQualitySummary => ({ ...board.qualitySummary }); +export const isInspiredesignReadyReference = ( + reference: InspiredesignReferencePatternBoard["references"][number] +): boolean => ( + reference.intentMatched + && reference.score >= MIN_READY_REFERENCE_SCORE + && reference.confidence >= MIN_READY_REFERENCE_CONFIDENCE +); + +export const isInspiredesignDesignReference = ( + reference: InspiredesignReferencePatternBoard["references"][number] +): boolean => ( + !isPinterestOwnedReferenceUrl(reference.url) || ( + isPinterestVisualReferenceUrl(reference.url) && isInspiredesignReadyReference(reference) + ) +); + +export const buildInspiredesignDesignReferencePatternBoard = ( + board: InspiredesignReferencePatternBoard, + designVectors: InspiredesignDesignVectors +): InspiredesignReferencePatternBoard => { + const references = board.references.filter(isInspiredesignDesignReference); + const notReadyCount = board.references.length - references.length; + const topReference = references[0]; + const missingScreenshotCount = references.filter((reference) => !reference.capturedVia.includes("visual")).length; + const qualitySummary: InspiredesignReferenceQualitySummary = { + rankedReferenceCount: references.length, + rejectedReferenceCount: board.rejectedReferences.length + notReadyCount, + failedCaptureCount: 0, + missingScreenshotCount, + diagnosticOnlyReasons: [...board.qualitySummary.diagnosticOnlyReasons], + ...(topReference + ? { + topReferenceScore: topReference.score, + topReferenceConfidence: topReference.confidence, + topReferenceIntentMatched: topReference.intentMatched + } + : {}) + }; + return { + ...board, + targetSurface: designVectors.surfaceIntent, + qualitySummary, + references, + rejectedReferences: [], + synthesis: { + dominantDirection: designVectors.directionLabel, + sharedStrengths: [...designVectors.patternsToBorrow], + sharedFailuresToAvoid: [...designVectors.patternsToReject], + contractDeltas: references.length > 0 + ? [...board.synthesis.contractDeltas] + : ["No ready reference evidence is available; keep implementation anchored to the source brief."] + } + }; +}; + export const buildInspiredesignReferencePatternBoard = ( briefId: string, format: InspiredesignBriefFormat, @@ -959,19 +1086,22 @@ export const buildInspiredesignDesignVectors = ( format: InspiredesignBriefFormat, board: InspiredesignReferencePatternBoard ): InspiredesignDesignVectors => { - const influence = board.synthesis.sharedStrengths.length > 0 - ? board.synthesis.sharedStrengths + const designReferences = board.references.filter(isInspiredesignDesignReference); + const designStrengths = designReferences.flatMap((entry) => entry.patternsToBorrow).slice(0, 6); + const influence = designStrengths.length > 0 + ? designStrengths : [format.archetype]; - const publicLandingEvidence = hasBoardPublicLandingEvidence(board); + const designBoard = { ...board, references: designReferences }; + const publicLandingEvidence = hasBoardPublicLandingEvidence(designBoard); const surfaceIntent = publicLandingEvidence ? "reference-led public landing page" : format.archetype; const compositionModel = publicLandingEvidence - ? ["full-bleed hero with narrative section cadence", ...board.references.map((entry) => entry.layoutRecipe)] - : [format.layoutArchetype, ...board.references.map((entry) => entry.layoutRecipe)]; + ? ["full-bleed hero with narrative section cadence", ...designReferences.map((entry) => entry.layoutRecipe)] + : [format.layoutArchetype, ...designReferences.map((entry) => entry.layoutRecipe)]; return { - sourcePriority: board.references.length > 0 ? "reference-evidence-first" : "brief-only", - directionLabel: board.synthesis.dominantDirection, + sourcePriority: designReferences.length > 0 ? "reference-evidence-first" : "brief-only", + directionLabel: designReferences[0]?.layoutRecipe ?? format.archetype, surfaceIntent, compositionModel: compositionModel.slice(0, 5), premiumPosture: [ @@ -985,16 +1115,16 @@ export const buildInspiredesignDesignVectors = ( "Respect reduced-motion preference with static hierarchy preserved.", format.motionGrammar ], - sectionArchitecture: buildSectionArchitecture(format, board), + sectionArchitecture: buildSectionArchitecture(format, designBoard), typographyPosture: [format.typographySystem], - imageryPosture: buildImageryPosture(format, board), - interactionDensity: buildInteractionDensity(format, board), - interactionMoments: buildInteractionMoments(format, board), - materialEffects: buildMaterialEffects(board), + imageryPosture: buildImageryPosture(format, designBoard), + interactionDensity: buildInteractionDensity(format, designBoard), + interactionMoments: buildInteractionMoments(format, designBoard), + materialEffects: buildMaterialEffects(designBoard), advancedMotionAdvisory: [...ADVANCED_MOTION_FIELDS], referenceInfluence: influence, - patternsToBorrow: board.references.flatMap((entry) => entry.patternsToBorrow).slice(0, 8), - patternsToReject: board.references.flatMap((entry) => entry.patternsToReject).slice(0, 8), + patternsToBorrow: designReferences.flatMap((entry) => entry.patternsToBorrow).slice(0, 8), + patternsToReject: designReferences.flatMap((entry) => entry.patternsToReject).slice(0, 8), guardrails: [...format.guardrails], antiPatterns: [...format.antiPatterns] }; diff --git a/src/providers/browser-native-discovery.ts b/src/providers/browser-native-discovery.ts index 08dcff4..73fd9f4 100644 --- a/src/providers/browser-native-discovery.ts +++ b/src/providers/browser-native-discovery.ts @@ -58,6 +58,11 @@ const htmlFromRecord = (record: NormalizedRecord): string => { return typeof html === "string" ? html : ""; }; +const linksFromRecord = (record: NormalizedRecord): string[] => { + const links = record.attributes.links; + return Array.isArray(links) ? links.filter((link): link is string => typeof link === "string") : []; +}; + const badStateTextForRecord = (record: NormalizedRecord): string => { return [ record.url ?? "", @@ -187,7 +192,8 @@ const extractRecipeReferenceUrls = ( extractor({ url: record.url ?? undefined, content: record.content ?? undefined, - html: typeof record.attributes.html === "string" ? record.attributes.html : undefined + html: typeof record.attributes.html === "string" ? record.attributes.html : undefined, + links: linksFromRecord(record) }).forEach(pushUrl); if (urls.length >= maxReferences) break; } diff --git a/src/providers/renderer.ts b/src/providers/renderer.ts index f1758c3..25011b3 100644 --- a/src/providers/renderer.ts +++ b/src/providers/renderer.ts @@ -187,6 +187,40 @@ const canContinueInspiredesignInCanvas = (guidance: NextStepGuidance | undefined guidance?.readiness === "ready" ); +const blockInspiredesignCanvasArtifactGuide = ( + handoff: InspiredesignFollowthrough, + recoverySummary: string +): InspiredesignFollowthrough["artifactGuide"] => ({ + ...handoff.artifactGuide, + [INSPIREDESIGN_HANDOFF_FILES.canvasPlanRequest]: { + ...handoff.artifactGuide[INSPIREDESIGN_HANDOFF_FILES.canvasPlanRequest], + purpose: "Diagnostic Canvas request preview only until nextStepGuidance.readiness is ready.", + howToUse: [ + recoverySummary, + "Do not submit this payload to Canvas until usable ranked references pass the readiness checks." + ], + mustNot: [ + "Do not submit canvas.plan.set while nextStepGuidance.readiness is not ready.", + "Do not treat rejected or not-ready references as Canvas design direction." + ] + } +}); + +const buildMissingInspiredesignGuidanceHandoff = (): { + followthroughSummary: string; + suggestedNextAction: string; + suggestedSteps: Array<{ reason: string; command?: string }>; +} => { + const summary = "Canvas continuation unavailable until nextStepGuidance.readiness is ready."; + return { + followthroughSummary: summary, + suggestedNextAction: summary, + suggestedSteps: [{ + reason: "Inspect the workflow output for nextStepGuidance before using Canvas artifacts." + }] + }; +}; + const limitedCount = (total: number, limit: number): number => Math.min(total, limit); const omissionLine = (args: { @@ -740,6 +774,7 @@ export const renderInspiredesign = (args: { const rankedReferences = args.rankedReferences ?? []; const rankedReferencesArtifact = args.referencePatternBoard ? { + qualitySummary: args.referencePatternBoard.qualitySummary, references: args.referencePatternBoard.references, rejectedReferences: args.referencePatternBoard.rejectedReferences, synthesis: args.referencePatternBoard.synthesis @@ -756,23 +791,31 @@ export const renderInspiredesign = (args: { meta: args.meta }); const followthroughSummary = prependPrimaryConstraint(args.designAgentHandoff.summary, args.meta); + const canContinueInCanvas = canContinueInspiredesignInCanvas(args.nextStepGuidance); + const prototypeGuidanceMarkdown = canContinueInCanvas ? args.prototypeGuidanceMarkdown : null; const commandExamples = { ...args.designAgentHandoff.commandExamples, - continueInCanvas: canContinueInspiredesignInCanvas(args.nextStepGuidance) + continueInCanvas: canContinueInCanvas ? args.designAgentHandoff.commandExamples.continueInCanvas : CANVAS_CONTINUATION_BLOCKED_COMMAND }; - const handoff = buildInspiredesignSuccessHandoff({ + const renderedWorkflowHandoff = buildInspiredesignSuccessHandoff({ summary: followthroughSummary, nextStep: args.designAgentHandoff.nextStep, commandExamples, deepCaptureRecommendation: args.designAgentHandoff.deepCaptureRecommendation, ...(args.nextStepGuidance ? { nextStepGuidance: args.nextStepGuidance } : {}) }); + const handoff = args.nextStepGuidance ? renderedWorkflowHandoff : buildMissingInspiredesignGuidanceHandoff(); + const blockedCanvasArtifactGuide = canContinueInCanvas + ? args.designAgentHandoff.artifactGuide + : blockInspiredesignCanvasArtifactGuide(args.designAgentHandoff, handoff.suggestedNextAction); const renderedDesignAgentHandoff = { ...args.designAgentHandoff, ...handoff, + summary: handoff.followthroughSummary, nextStep: handoff.suggestedNextAction, + artifactGuide: blockedCanvasArtifactGuide, commandExamples }; const contextPayload = { @@ -787,7 +830,7 @@ export const renderInspiredesign = (args: { implementationPlan: args.implementationPlan, designMarkdown: args.designMarkdown, implementationPlanMarkdown: args.implementationPlanMarkdown, - prototypeGuidanceMarkdown: args.prototypeGuidanceMarkdown, + prototypeGuidanceMarkdown, evidence: args.evidence, visualEvidence, screenshotIndex, @@ -813,8 +856,8 @@ export const renderInspiredesign = (args: { { path: INSPIREDESIGN_HANDOFF_FILES.rankedReferences, content: rankedReferencesArtifact }, { path: INSPIREDESIGN_HANDOFF_FILES.metaPrompt, content: metaPromptMarkdown } ]; - if (args.prototypeGuidanceMarkdown) { - files.push({ path: INSPIREDESIGN_HANDOFF_FILES.prototypeGuidance, content: args.prototypeGuidanceMarkdown }); + if (prototypeGuidanceMarkdown) { + files.push({ path: INSPIREDESIGN_HANDOFF_FILES.prototypeGuidance, content: prototypeGuidanceMarkdown }); } const captureAttemptFields = { ...(captureAttemptSummary ? { captureAttemptSummary } : {}), @@ -846,7 +889,7 @@ export const renderInspiredesign = (args: { designContract: args.designContract, generationPlan: args.generationPlan, implementationPlan: args.implementationPlan, - prototypeGuidanceMarkdown: args.prototypeGuidanceMarkdown, + prototypeGuidanceMarkdown, evidence: args.evidence, visualEvidence, screenshotIndex, @@ -865,7 +908,7 @@ export const renderInspiredesign = (args: { mode: args.mode, markdown: args.designMarkdown, implementationPlanMarkdown: args.implementationPlanMarkdown, - prototypeGuidanceMarkdown: args.prototypeGuidanceMarkdown, + prototypeGuidanceMarkdown, ...handoff, ...captureAttemptFields, meta: args.meta diff --git a/src/providers/workflow-handoff.ts b/src/providers/workflow-handoff.ts index 1a58f65..0b23300 100644 --- a/src/providers/workflow-handoff.ts +++ b/src/providers/workflow-handoff.ts @@ -273,6 +273,11 @@ type InspiredesignSuccessHandoffInput = { nextStepGuidance?: NextStepGuidance; }; +const extractPrimaryConstraintSentence = (summary: string): string | null => { + const match = /^Primary constraint:\s+(.+?\.)\s/.exec(summary); + return match?.[1] ?? null; +}; + const buildMacroResolveArgs = ( input: MacroResolveHandoffInput, options?: { @@ -450,8 +455,9 @@ export const buildInspiredesignSuccessHandoff = ( input: InspiredesignSuccessHandoffInput ): WorkflowSuccessHandoff => { if (input.nextStepGuidance && input.nextStepGuidance.readiness !== "ready") { - const summary = input.summary.startsWith("Primary constraint:") - ? `${input.summary} ${input.nextStepGuidance.primaryAction.summary}` + const primaryConstraint = extractPrimaryConstraintSentence(input.summary); + const summary = primaryConstraint + ? `Primary constraint: ${primaryConstraint} ${input.nextStepGuidance.primaryAction.summary}` : undefined; const compatibility = renderWorkflowCompatibility(input.nextStepGuidance, summary); return { diff --git a/src/providers/workflows.ts b/src/providers/workflows.ts index fce2db4..82fe41e 100644 --- a/src/providers/workflows.ts +++ b/src/providers/workflows.ts @@ -43,7 +43,7 @@ import { type NextStepGuidance, type SiteRecipe } from "../guidance"; -import { resolveSiteRecipeForProvider } from "../guidance/recipes/site-registry"; +import { resolveSiteRecipeForProvider, resolveSiteRecipeForUrl } from "../guidance/recipes/site-registry"; import { mergeInspiredesignReferenceUrls, normalizeInspiredesignDiscoveryRecords, @@ -1807,22 +1807,66 @@ const buildInspiredesignStepEnvelope = ( } ); -const buildInspiredesignFetchOptions = ( +const buildInspiredesignFetchOptionsWithScope = ( workflowInput: InspiredesignResolvedInput, envelope: WorkflowResumeEnvelope, + providerScope: Pick, timeoutMs?: number -): ProviderRunOptions => withWorkflowResumeEnvelopeIntent( - withBrowserModeOverride( - withChallengeAutomationOverride( - withCookieOverrides({ - ...(typeof timeoutMs === "number" ? { timeoutMs } : {}) - }, workflowInput), +): ProviderRunOptions => ( + withWorkflowResumeEnvelopeIntent( + withBrowserModeOverride( + withChallengeAutomationOverride( + withCookieOverrides({ + ...providerScope, + ...(typeof timeoutMs === "number" ? { timeoutMs } : {}) + }, workflowInput), + workflowInput + ), workflowInput ), - workflowInput - ), - "workflow.inspiredesign", - envelope + "workflow.inspiredesign", + envelope + ) +); + +const buildInspiredesignFetchOptions = ( + workflowInput: InspiredesignResolvedInput, + envelope: WorkflowResumeEnvelope, + timeoutMs?: number +): ProviderRunOptions => { + const siteRecipeProviderIds = new Set( + workflowInput.providers.filter((providerId) => resolveSiteRecipeForProvider(providerId) !== undefined) + ); + const standardProviderIds = workflowInput.providers.filter((providerId) => !siteRecipeProviderIds.has(providerId)); + let providerScope: Pick = {}; + if (workflowInput.providers.length > 0 && standardProviderIds.length === 0) { + providerScope = { source: "web" }; + } else if (standardProviderIds.length > 0) { + providerScope = { providerIds: standardProviderIds }; + } + return buildInspiredesignFetchOptionsWithScope(workflowInput, envelope, providerScope, timeoutMs); +}; + +const buildInspiredesignSiteRecipeFetchOptions = ( + workflowInput: InspiredesignResolvedInput, + envelope: WorkflowResumeEnvelope, + timeoutMs?: number +): ProviderRunOptions => buildInspiredesignFetchOptionsWithScope( + workflowInput, + envelope, + { source: "web" }, + timeoutMs +); + +const buildInspiredesignReferenceFetchOptions = ( + workflowInput: InspiredesignResolvedInput, + envelope: WorkflowResumeEnvelope, + url: string, + timeoutMs?: number +): ProviderRunOptions => ( + resolveSiteRecipeForUrl(url) + ? buildInspiredesignSiteRecipeFetchOptions(workflowInput, envelope, timeoutMs) + : buildInspiredesignFetchOptions(workflowInput, envelope, timeoutMs) ); type InspiredesignDiscoveryDiagnostics = { @@ -1918,6 +1962,49 @@ const normalizeSiteRecipeFetchFailures = ( })); }; +type InspiredesignAcceptedDiscovery = InspiredesignDiscoveryResult["accepted"][number]; + +const appendNextUniqueDiscoveryCandidate = ( + queue: InspiredesignAcceptedDiscovery[], + cursor: number, + seen: Set, + accepted: InspiredesignAcceptedDiscovery[] +): number => { + let nextCursor = cursor; + while (nextCursor < queue.length) { + const candidate = queue[nextCursor]; + nextCursor += 1; + if (!candidate || seen.has(candidate.url)) continue; + seen.add(candidate.url); + accepted.push(candidate); + return nextCursor; + } + return nextCursor; +}; + +const capMixedInspiredesignDiscovery = ( + siteDiscovery: InspiredesignDiscoveryResult, + standardDiscovery: InspiredesignDiscoveryResult, + maxReferences: number +): InspiredesignDiscoveryResult => { + const accepted: InspiredesignDiscoveryResult["accepted"] = []; + const seen = new Set(); + let siteCursor = 0; + let standardCursor = 0; + while ( + accepted.length < maxReferences + && (siteCursor < siteDiscovery.accepted.length || standardCursor < standardDiscovery.accepted.length) + ) { + siteCursor = appendNextUniqueDiscoveryCandidate(siteDiscovery.accepted, siteCursor, seen, accepted); + if (accepted.length >= maxReferences) break; + standardCursor = appendNextUniqueDiscoveryCandidate(standardDiscovery.accepted, standardCursor, seen, accepted); + } + return { + accepted, + rejected: [...siteDiscovery.rejected, ...standardDiscovery.rejected] + }; +}; + const discoverInspiredesignReferences = async ( runtime: ReferenceRetrievalPort, workflowInput: InspiredesignResolvedInput, @@ -1946,7 +2033,7 @@ const discoverInspiredesignReferences = async ( fetchSearchPage: async (url) => { const result = normalizeInspiredesignFetchResult(await runtime.fetch( { url }, - buildInspiredesignFetchOptions(workflowInput, envelope, timeoutMs) + buildInspiredesignSiteRecipeFetchOptions(workflowInput, envelope, timeoutMs) )); const failures = result.failures.length > 0 ? result.failures : failureFromInspiredesignFetchError(result); return { @@ -1974,7 +2061,11 @@ const discoverInspiredesignReferences = async ( const discovery = normalizeInspiredesignDiscoveryRecords(searchResult.records); const siteResult = await runSiteRecipeDiscovery(); const siteDiscovery = normalizeInspiredesignDiscoveryRecords(siteResult.records); - const combinedDiscovery = normalizeInspiredesignDiscoveryRecords([...siteResult.records, ...searchResult.records]); + const combinedDiscovery = capMixedInspiredesignDiscovery( + siteDiscovery, + discovery, + workflowInput.referenceLimit ?? workflowInput.maxReferences + ); const failures = [...searchFailures, ...siteResult.failures]; return { requested: true, @@ -1990,7 +2081,8 @@ const discoverInspiredesignReferences = async ( ...siteResult.diagnostics, standardAcceptedCount: discovery.accepted.length, standardRejectedCount: discovery.rejected.length, - siteAcceptedCount: siteDiscovery.accepted.length + siteAcceptedCount: siteDiscovery.accepted.length, + cappedAcceptedCount: combinedDiscovery.accepted.length } }; } catch (error) { @@ -2722,6 +2814,47 @@ const hasSurvivingInspiredesignReference = (references: InspiredesignReferenceEv references.some((reference) => reference.fetchStatus === "captured" || reference.captureStatus === "captured") ); +const INSPIREDESIGN_HARD_DISCOVERY_REASON_CODES = new Set([ + "auth_required", + "challenge_detected", + "policy_blocked", + "rate_limited", + "token_required" +]); + +const readInspiredesignFailureReasonCode = (failure: ProviderFailureEntry): ProviderReasonCode | undefined => { + const reasonCode = failure.error.reasonCode ?? failure.error.details?.reasonCode; + return typeof reasonCode === "string" && INSPIREDESIGN_HARD_DISCOVERY_REASON_CODES.has(reasonCode as ProviderReasonCode) + ? reasonCode as ProviderReasonCode + : undefined; +}; + +const hardInspiredesignDiscoveryReasonCodes = (discovery: InspiredesignDiscoveryDiagnostics): ProviderReasonCode[] => { + const reasonCodes = discovery.failures + .map(readInspiredesignFailureReasonCode) + .filter((reasonCode): reasonCode is ProviderReasonCode => reasonCode !== undefined); + return [...new Set(reasonCodes)]; +}; + +const hardInspiredesignMetaReasonCodes = (meta: Record): ProviderReasonCode[] => { + const distribution = meta.reasonCodeDistribution; + if (!distribution || typeof distribution !== "object" || Array.isArray(distribution)) return []; + return Object.keys(distribution) + .filter((reasonCode): reasonCode is ProviderReasonCode => ( + INSPIREDESIGN_HARD_DISCOVERY_REASON_CODES.has(reasonCode as ProviderReasonCode) + )); +}; + +const hardInspiredesignGuidanceReasonCodes = ( + discovery: InspiredesignDiscoveryDiagnostics, + meta: Record +): ProviderReasonCode[] => [ + ...new Set([ + ...hardInspiredesignDiscoveryReasonCodes(discovery), + ...hardInspiredesignMetaReasonCodes(meta) + ]) +]; + const selectInspiredesignPrimaryConstraintFailures = ( failures: ProviderFailureEntry[], references: InspiredesignReferenceEvidence[], @@ -2773,16 +2906,18 @@ const buildInspiredesignGuidanceSource = ( requested: discovery.requested, acceptedUrls: discovery.acceptedUrls, failures: discovery.failures.length, - ...(discovery.failure ? { failure: discovery.failure } : {}) + ...(discovery.failure ? { failure: discovery.failure } : {}), + hardFailureReasonCodes: hardInspiredesignGuidanceReasonCodes(discovery, meta) }, metrics: { - referenceCount: referencePatternBoard.references.length + referencePatternBoard.rejectedReferences.length, + referenceCount: quality.rankedReferenceCount + quality.rejectedReferenceCount, referenceEvidenceRequired: isInspiredesignReferenceEvidenceRequired(workflowInput), failedCaptureCount: quality.failedCaptureCount, visualEvidenceRequired: workflowInput.visualEvidence === "required" }, quality: { rankedReferenceCount: quality.rankedReferenceCount, + rankedReferenceUrls: referencePatternBoard.references.map((reference) => reference.url), rejectedReferenceCount: quality.rejectedReferenceCount, missingScreenshotCount: quality.missingScreenshotCount, diagnosticOnlyReasons: quality.diagnosticOnlyReasons, @@ -2915,18 +3050,15 @@ const summarizeInspiredesignDiscoveryConstraint = ( }; const buildInspiredesignGuidanceFollowthroughSummary = ( - followthrough: InspiredesignFollowthrough, + _followthrough: InspiredesignFollowthrough, meta: Record, nextStepGuidance: NextStepGuidance ): string => { const primaryConstraintSummary = typeof meta.primaryConstraintSummary === "string" ? meta.primaryConstraintSummary.trim() : ""; - const constrainedSummary = primaryConstraintSummary - ? `Primary constraint: ${primaryConstraintSummary} ${followthrough.summary}` - : followthrough.summary; - const fallbackSummary = constrainedSummary.startsWith("Primary constraint:") - ? `${constrainedSummary} ${nextStepGuidance.primaryAction.summary}` + const fallbackSummary = primaryConstraintSummary + ? `Primary constraint: ${primaryConstraintSummary} ${nextStepGuidance.primaryAction.summary}` : undefined; return renderWorkflowCompatibility(nextStepGuidance, fallbackSummary).followthroughSummary; }; @@ -4227,9 +4359,10 @@ export const runInspiredesignWorkflow = async ( const fetchTimeoutMs = remainingTimeoutMs(); const fetchResult = await runtime.fetch( { url }, - buildInspiredesignFetchOptions( + buildInspiredesignReferenceFetchOptions( workflowInput, buildInspiredesignStepEnvelope(workflowInput, stepTrace, index, url), + url, fetchTimeoutMs ) ); @@ -4253,7 +4386,9 @@ export const runInspiredesignWorkflow = async ( const reference = buildInspiredesignReference(url, result, capture); references.push(reference); if (reference.fetchStatus === "failed" && !isInspiredesignFetchRecovered(reference)) { - failures.push(...result.failures); + const fetchFailures = result.failures.length > 0 ? result.failures : failureFromInspiredesignFetchError(result); + const siteRecipe = resolveSiteRecipeForUrl(url); + failures.push(...(siteRecipe ? normalizeSiteRecipeFetchFailures(siteRecipe, fetchFailures) : fetchFailures)); } trace = appendWorkflowTrace(stepTrace, "execute", "reference_completed", { stepIndex: index, @@ -4269,7 +4404,8 @@ export const runInspiredesignWorkflow = async ( briefExpansion: workflowInput.briefExpansion, urls: workflowInput.urls, references: visualCollation.references, - includePrototypeGuidance: workflowInput.includePrototypeGuidance + includePrototypeGuidance: workflowInput.includePrototypeGuidance, + referenceEvidenceRequired: isInspiredesignReferenceEvidenceRequired(workflowInput) }); const meta = buildInspiredesignMeta( runtime, diff --git a/tests/guidance-context.test.ts b/tests/guidance-context.test.ts index 3ea9485..5e2c969 100644 --- a/tests/guidance-context.test.ts +++ b/tests/guidance-context.test.ts @@ -233,6 +233,47 @@ describe("guidance context adapters", () => { expect(context.siteRecipeId).toBeUndefined(); }); + it("suppresses Pinterest hard failures only when the same Pinterest URL survives as ranked evidence", () => { + const source = { + brief: "Design a premium studio landing page", + urls: ["https://www.pinterest.com/pin/61572719900827789/"], + requestedProviders: ["social/pinterest"], + discovery: { + requested: true, + acceptedUrls: ["https://example.com/reference"], + failures: 1, + hardFailureReasonCodes: ["auth_required"] + }, + metrics: { + referenceCount: 1, + failedCaptureCount: 0, + visualEvidenceRequired: false + }, + quality: { + rankedReferenceCount: 1, + rankedReferenceUrls: ["https://example.com/reference"], + rejectedReferenceCount: 0, + topReferenceScore: 88, + topReferenceConfidence: 0.9, + missingScreenshotCount: 0, + diagnosticOnlyReasons: [] + } + }; + + expect(createInspiredesignGuidanceContext(source).providerUnavailable).toBe(true); + expect(createInspiredesignGuidanceContext({ + ...source, + discovery: { + ...source.discovery, + acceptedUrls: ["https://www.pinterest.com/pin/61572719900827789/"] + }, + quality: { + ...source.quality, + rankedReferenceUrls: ["https://www.pinterest.com/pin/61572719900827789/"] + } + }).reasonCode).toBe("design_ready"); + }); + it("keeps brief-only Inspired Design handoffs ready when reference evidence was not required", () => { const context = createInspiredesignGuidanceContext({ brief: "Design a premium studio landing page", diff --git a/tests/pinterest-guidance-recipe.test.ts b/tests/pinterest-guidance-recipe.test.ts index db38bd2..fee4c04 100644 --- a/tests/pinterest-guidance-recipe.test.ts +++ b/tests/pinterest-guidance-recipe.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { normalizePinterestReferenceUrl } from "../src/guidance/recipes/pinterest"; import { listSiteRecipes, resolveSiteRecipeForProvider, @@ -37,9 +38,20 @@ const makeFailure = ( }); describe("Pinterest guidance recipe", () => { + it("canonicalizes accepted Pinterest references to HTTPS", () => { + expect(normalizePinterestReferenceUrl("http://www.pinterest.com/pin/61572719900827789/?tracking=1#section")).toBe( + "https://www.pinterest.com/pin/61572719900827789/" + ); + expect(normalizePinterestReferenceUrl("https://www.pinterest.com/pin/create/")).toBeNull(); + expect(normalizePinterestReferenceUrl("https://www.pinterest.com/pin/edit/")).toBeNull(); + expect(normalizePinterestReferenceUrl("https://www.pinterest.com/ideas/create/")).toBeNull(); + expect(normalizePinterestReferenceUrl("http://evil-pinterest.com/pin/61572719900827789/")).toBeNull(); + }); + it("resolves Pinterest by provider id and host without registering a social provider", () => { expect(resolveSiteRecipeForProvider("social/pinterest")?.id).toBe("social/pinterest"); expect(resolveSiteRecipeForUrl("https://uk.pinterest.com/ideas/web-design-parallax-scrolling/896364491640/")?.id).toBe("social/pinterest"); + expect(resolveSiteRecipeForUrl("https://assets.pinterest.com/pin/61572719900827789/")).toBeUndefined(); expect(resolveSiteRecipeForUrl("not a url")).toBeUndefined(); expect(resolveSiteRecipeForProvider("social/not-pinterest")).toBeUndefined(); @@ -272,6 +284,45 @@ describe("Pinterest guidance recipe", () => { ]); }); + it("extracts Pinterest references from browser fallback link attributes before search-shell recovery", async () => { + const recipe = resolveSiteRecipeForProvider("social/pinterest"); + expect(recipe).toBeDefined(); + if (!recipe) return; + + const result = await runBrowserNativeDiscovery({ + recipe, + query: "premium design agency studio landing page", + maxReferences: 3, + browserMode: "extension", + useCookies: true, + cookiePolicy: "required", + fetchSearchPage: async () => ({ + records: [makeSearchRecord({ + title: "Pinterest", + content: "Your profile Pin card", + attributes: { + links: [ + "https://uk.pinterest.com/pin/11188699075430754/", + "/pin/27654985208435505/", + "https://example.com/not-pinterest" + ] + } + })], + failures: [] + }) + }); + + expect(result.failures).toEqual([]); + expect(result.records.map((record) => record.url)).toEqual([ + "https://uk.pinterest.com/pin/11188699075430754/", + "https://www.pinterest.com/pin/27654985208435505/" + ]); + expect(result.diagnostics).toEqual(expect.objectContaining({ + reason: "reference_urls_extracted", + extractedUrlCount: 2 + })); + }); + it("classifies search-shell pages after extraction fails", async () => { const recipe = resolveSiteRecipeForProvider("social/pinterest"); expect(recipe).toBeDefined(); @@ -344,6 +395,7 @@ describe("Pinterest guidance recipe", () => { content: [ 'Profile pins', 'Following', + 'Reserved board path', 'Board', 'Tracked duplicate', 'Pin' @@ -596,7 +648,7 @@ describe("Pinterest guidance recipe", () => { })); }); - it("rejects non-concrete Pinterest pin and idea paths from direct candidate URLs", async () => { + it("rejects non-concrete Pinterest pin, idea, and product chrome paths from direct candidate URLs", async () => { const recipe = resolveSiteRecipeForProvider("social/pinterest"); expect(recipe).toBeDefined(); if (!recipe) return; @@ -611,7 +663,12 @@ describe("Pinterest guidance recipe", () => { fetchSearchPage: async () => ({ records: [ makeSearchRecord({ url: "https://www.pinterest.com/pin/" }), - makeSearchRecord({ url: "https://www.pinterest.com/ideas/" }) + makeSearchRecord({ url: "https://www.pinterest.com/pin/create/" }), + makeSearchRecord({ url: "https://www.pinterest.com/pin/edit/" }), + makeSearchRecord({ url: "https://www.pinterest.com/ideas/" }), + makeSearchRecord({ url: "https://www.pinterest.com/ideas/create/" }), + makeSearchRecord({ url: "https://www.pinterest.com/create/pin/" }), + makeSearchRecord({ url: "https://www.pinterest.com/explore/design/" }) ], failures: [] }) @@ -621,7 +678,7 @@ describe("Pinterest guidance recipe", () => { expect(result.diagnostics.reason).toBe("no_reference_urls_extracted"); }); - it("rejects spoofed hosts and reports the default extraction message", async () => { + it("rejects spoofed and unapproved Pinterest hosts with the default extraction message", async () => { const recipe = resolveSiteRecipeForProvider("social/pinterest"); expect(recipe).toBeDefined(); if (!recipe) return; @@ -635,8 +692,8 @@ describe("Pinterest guidance recipe", () => { cookiePolicy: "required", fetchSearchPage: async () => ({ records: [makeSearchRecord({ - url: "https://evil.example/pin/61572719900827789/", - content: "https://notpinterest.com/pin/61572719900827789/" + url: "https://assets.pinterest.com/pin/61572719900827789/", + content: "https://notpinterest.com/pin/61572719900827789/ https://assets.pinterest.com/pin/61572719900827789/" })], failures: [] }) @@ -646,6 +703,30 @@ describe("Pinterest guidance recipe", () => { expect(result.failures[0]?.error.message).toBe("social/pinterest search did not expose recipe-approved URLs that can be used as references."); }); + it("rejects non-http Pinterest URLs from direct candidate records", async () => { + const recipe = resolveSiteRecipeForProvider("social/pinterest"); + expect(recipe).toBeDefined(); + if (!recipe) return; + + const result = await runBrowserNativeDiscovery({ + recipe, + query: "cinematic photography studio", + maxReferences: 2, + browserMode: "extension", + useCookies: true, + cookiePolicy: "required", + fetchSearchPage: async () => ({ + records: [makeSearchRecord({ + url: "ftp://www.pinterest.com/pin/61572719900827789/" + })], + failures: [] + }) + }); + + expect(result.records).toEqual([]); + expect(result.diagnostics.reason).toBe("no_reference_urls_extracted"); + }); + it("extracts references from record html attributes and stops at the requested maximum", async () => { const recipe = resolveSiteRecipeForProvider("social/pinterest"); expect(recipe).toBeDefined(); diff --git a/tests/providers-inspiredesign-contract.test.ts b/tests/providers-inspiredesign-contract.test.ts index a918eb3..9c6c263 100644 --- a/tests/providers-inspiredesign-contract.test.ts +++ b/tests/providers-inspiredesign-contract.test.ts @@ -24,7 +24,10 @@ import { buildInspiredesignFollowthroughSummary, buildInspiredesignNextStep } from "../src/inspiredesign/handoff"; -import { hasInspiredesignUsableReferenceEvidence } from "../src/inspiredesign/reference-pattern-board"; +import { + buildInspiredesignReferencePatternBoard, + hasInspiredesignUsableReferenceEvidence +} from "../src/inspiredesign/reference-pattern-board"; import { renderInspiredesign } from "../src/providers/renderer"; import { buildInspiredesignSuccessHandoff } from "../src/providers/workflow-handoff"; @@ -479,7 +482,7 @@ describe("inspiredesign packet + renderer", () => { expect(packet.prototypeGuidanceMarkdown).toContain("# 6. Optional Prototype Plan"); expect(packet.advancedBriefMarkdown).toContain("Reference-led public landing page"); - expect(packet.designContract.intent.referenceCount).toBe(3); + expect(packet.designContract.intent.referenceCount).toBe(2); expect(evidence.briefExpansion.templateVersion).toBe("inspiredesign-advanced-brief.v1"); expect(evidence.advancedBrief).toContain("Prompt objective:"); expect(evidence.urls).toEqual([ @@ -654,13 +657,8 @@ describe("inspiredesign packet + renderer", () => { expect(evidence.referencePatternBoard?.synthesis.dominantDirection).toBe( evidence.referencePatternBoard?.references[0]?.layoutRecipe ); - expect(evidence.referencePatternBoard?.rejectedReferences).toEqual([ - expect.objectContaining({ - id: "blocked", - fetchStatus: "failed", - captureStatus: "failed" - }) - ]); + expect(evidence.referencePatternBoard?.rejectedReferences).toEqual([]); + expect(evidence.referencePatternBoard?.qualitySummary.rejectedReferenceCount).toBeGreaterThan(0); expect(evidence.rankedReferences).toEqual(evidence.referencePatternBoard?.references); expect(evidence.visualEvidence).toEqual([ expect.objectContaining({ @@ -741,11 +739,8 @@ describe("inspiredesign packet + renderer", () => { const board = (packet.evidence as InspiredesignEvidenceJson).referencePatternBoard; expect(board?.references[0]).toBeDefined(); - expect(board?.rejectedReferences[0]).toBeDefined(); + expect(board?.rejectedReferences).toEqual([]); expect(Object.keys(template.references[0] ?? {}).sort()).toEqual(Object.keys(board?.references[0] ?? {}).sort()); - expect(Object.keys(template.rejectedReferences[0] ?? {}).sort()).toEqual( - Object.keys(board?.rejectedReferences[0] ?? {}).sort() - ); expect(Object.keys(template.synthesis).sort()).toEqual(Object.keys(board?.synthesis ?? {}).sort()); }); @@ -816,9 +811,8 @@ describe("inspiredesign packet + renderer", () => { ].join(" "); expect(evidence.referencePatternBoard?.references.map((reference) => reference.id)).toEqual(["coffee-roaster"]); - expect(evidence.referencePatternBoard?.rejectedReferences).toEqual([ - expect.objectContaining({ id: "pinterest-shell" }) - ]); + expect(evidence.referencePatternBoard?.rejectedReferences).toEqual([]); + expect(evidence.referencePatternBoard?.qualitySummary.rejectedReferenceCount).toBe(1); expect(guidanceText).not.toContain("Your profile"); expect(guidanceText).not.toContain("Pin card"); expect(guidanceText).not.toContain("--gestalt-theme"); @@ -1233,6 +1227,287 @@ describe("inspiredesign packet + renderer", () => { } } }))).toBe(false); + + expect(hasInspiredesignUsableReferenceEvidence(makeReference({ + url: "https://www.pinterest.com/pin/955748352150564605/", + fetchStatus: "failed", + captureStatus: "captured", + title: "[r1] link \"Skip to content\" [r2] link \"Your profile\" [r3] button \"Accounts\" [r4] link \"Home\"", + excerpt: "[r1] link \"Skip to content\" [r2] link \"Your profile\" [r3] button \"Accounts\" [r4] link \"Home\" [r5] link \"Your boards\" [r6] button \"Settings & Support\" [r7] button \"Updates\" [r8] button \"Messages\"", + fetchFailure: "Provider circuit is open", + capture: { + visual: { + status: "captured", + path: "/tmp/pinterest-chrome.png", + sha256: "e".repeat(64), + warnings: [] + } + } + }))).toBe(false); + }); + + it("keeps screenshot-backed Pinterest pin references usable when page chrome surrounds clean pin metadata", () => { + const reference = makeReference({ + id: "pinterest-pin", + url: "https://www.pinterest.com/pin/11188699075430754/", + fetchStatus: "captured", + captureStatus: "captured", + title: "KINETIC | CREATIVE AGENCY STUDIO WEBSITE | DESIGN BY.SHIVAA", + excerpt: "KINETIC | CREATIVE AGENCY STUDIO WEBSITE | DESIGN BY.SHIVAA Skip to content When autocomplete results are available use up and down arrows to review and enter to select. Touch device users, explore by touch or with swipe gestures.", + capture: { + title: "KINETIC | CREATIVE AGENCY STUDIO WEBSITE | DESIGN BY.SHIVAA", + snapshot: { + content: "KINETIC | CREATIVE AGENCY STUDIO WEBSITE | DESIGN BY.SHIVAA Skip to content When autocomplete results are available use up and down arrows to review and enter to select. Touch device users, explore by touch or with swipe gestures." + }, + visual: { + status: "captured", + path: "/tmp/pinterest-pin.png", + sha256: "d".repeat(64), + warnings: [] + } + } + }); + + const board = buildInspiredesignReferencePatternBoard( + "pinterest-pin", + makeBriefFormat(), + [reference], + "Design a premium landing page prototype for a design agency studio." + ); + + expect(hasInspiredesignUsableReferenceEvidence(reference)).toBe(true); + expect(board.references[0]).toEqual(expect.objectContaining({ + id: "pinterest-pin", + capturedVia: expect.arrayContaining(["fetch", "visual"]) + })); + expect(board.references[0]?.score).toBeLessThan(50); + expect(board.rejectedReferences).toEqual([]); + }); + + it("keeps Pinterest chrome-only screenshot metadata out of design-facing artifacts", () => { + const references = Array.from({ length: 5 }, (_, index) => { + const pinId = `1118869907543075${index}`; + const title = `Kinetic creative agency studio website concept ${index + 1}`; + return makeReference({ + id: `chrome-pin-${index + 1}`, + url: `https://www.pinterest.com/pin/${pinId}/`, + fetchStatus: "captured", + captureStatus: "captured", + title, + excerpt: `${title} Skip to content When autocomplete results are available use up and down arrows to review and enter to select. Touch device users, explore by touch or with swipe gestures.`, + capture: { + title, + snapshot: { + content: `${title} Skip to content Your profile Pin card Home Updates Messages` + }, + visual: { + status: "captured", + path: `/tmp/chrome-pin-${index + 1}.png`, + sha256: `${index}`.repeat(64).slice(0, 64), + warnings: [] + } + } + }); + }); + + const packet = buildInspiredesignPacket({ + brief: "Design a premium landing page prototype for a design agency studio.", + briefExpansion: makeBriefExpansion({ + sourceBrief: "Design a premium landing page prototype for a design agency studio." + }), + urls: references.map((reference) => reference.url), + references + }); + const designFacingArtifacts = JSON.stringify({ + rankedReferences: packet.rankedReferences, + board: packet.generationPlan.referencePatternBoard, + canvas: packet.canvasPlanRequest, + designContract: packet.designContract, + generationPlan: packet.generationPlan, + followthrough: packet.followthrough.implementationContext, + metaPrompt: packet.metaPromptMarkdown, + designMarkdown: packet.designMarkdown, + implementationPlan: packet.implementationPlan, + implementationPlanMarkdown: packet.implementationPlanMarkdown + }); + + expect(packet.rankedReferences).toEqual([]); + expect(packet.generationPlan.designVectors.sourcePriority).toBe("brief-only"); + expect(packet.generationPlan.referencePatternBoard.qualitySummary).toMatchObject({ + rankedReferenceCount: 0, + rejectedReferenceCount: 5, + missingScreenshotCount: 0 + }); + expect(packet.advancedBriefMarkdown.indexOf("Reference evidence unavailable:")).toBe(0); + expect(designFacingArtifacts).not.toContain("Kinetic creative agency studio website concept"); + expect(designFacingArtifacts).not.toContain("1118869907543075"); + expect(designFacingArtifacts).not.toContain("/tmp/chrome-pin"); + }); + + it("keeps clean screenshot-backed Pinterest pins usable without interface-chrome diagnostics", () => { + const reference = makeReference({ + id: "clean-pinterest-pin", + url: "https://www.pinterest.com/pin/27654985208435505/", + fetchStatus: "captured", + captureStatus: "captured", + title: "Pumpkin modern creative agency landing page", + excerpt: "Premium design agency studio landing page with editorial typography, cinematic hero motion, portfolio proof, and service sections.", + capture: { + title: "Pumpkin modern creative agency landing page", + visual: { + status: "captured", + path: "/tmp/clean-pinterest-pin.png", + sha256: "c".repeat(64), + warnings: [] + } + } + }); + + const board = buildInspiredesignReferencePatternBoard( + "clean-pinterest-pin", + makeBriefFormat(), + [reference], + "Design a premium landing page prototype for a design agency studio." + ); + + expect(hasInspiredesignUsableReferenceEvidence(reference)).toBe(true); + expect(board.references[0]).toEqual(expect.objectContaining({ + id: "clean-pinterest-pin", + capturedVia: expect.arrayContaining(["fetch", "visual"]), + intentMatched: true + })); + expect(board.references[0]?.score).toBeGreaterThanOrEqual(50); + expect(board.references[0]?.confidence).toBeGreaterThanOrEqual(0.5); + expect(board.qualitySummary).toMatchObject({ + failedCaptureCount: 0, + missingScreenshotCount: 0 + }); + }); + + it("rejects Pinterest-owned asset hosts from creative synthesis", () => { + const reference = makeReference({ + id: "asset-pinterest-pin", + url: "https://assets.pinterest.com/pin/27654985208435505/", + fetchStatus: "captured", + captureStatus: "captured", + title: "Pumpkin modern creative agency landing page", + excerpt: "Premium design agency studio landing page with editorial typography, cinematic hero motion, portfolio proof, and service sections.", + capture: { + title: "Pumpkin modern creative agency landing page", + visual: { + status: "captured", + path: "/tmp/asset-pinterest-pin.png", + sha256: "b".repeat(64), + warnings: [] + } + } + }); + + const board = buildInspiredesignReferencePatternBoard( + "asset-pinterest-pin", + makeBriefFormat(), + [reference], + "Design a premium landing page prototype for a design agency studio." + ); + + expect(hasInspiredesignUsableReferenceEvidence(reference)).toBe(false); + expect(board.references).toEqual([]); + expect(board.rejectedReferences).toEqual([ + expect.objectContaining({ + id: "asset-pinterest-pin", + reason: "Pinterest reference host is not approved for creative synthesis." + }) + ]); + }); + + it("keeps weak off-brief Pinterest references out of Canvas design direction", () => { + const packet = buildInspiredesignPacket({ + brief: "Design a premium landing page prototype for a design agency studio.", + briefExpansion: makeBriefExpansion({ + sourceBrief: "Design a premium landing page prototype for a design agency studio." + }), + urls: [ + "https://www.pinterest.com/pin/31525266137895345/", + "https://www.pinterest.com/offbrief/portal-board/" + ], + references: [ + makeReference({ + id: "off-brief-pin", + url: "https://www.pinterest.com/pin/31525266137895345/", + fetchStatus: "captured", + captureStatus: "captured", + title: "Estilo de portal digital funcional", + excerpt: "Estilo de portal digital funcional Skip to content When autocomplete results are available use up and down arrows to review and enter to select. Touch device users, explore by touch or with swipe gestures.", + capture: { + title: "Estilo de portal digital funcional", + visual: { + status: "captured", + path: "/tmp/off-brief-pin.png", + sha256: "f".repeat(64), + warnings: [] + } + } + }), + makeReference({ + id: "off-brief-board", + url: "https://www.pinterest.com/offbrief/portal-board/", + fetchStatus: "captured", + captureStatus: "captured", + title: "Functional portal moodboard", + excerpt: "Functional portal moodboard with app chrome and operational panels.", + capture: { + title: "Functional portal moodboard", + visual: { + status: "captured", + path: "/tmp/off-brief-board.png", + sha256: "a".repeat(64), + warnings: [] + } + } + }) + ] + }); + const evidence = packet.evidence as InspiredesignEvidenceJson; + + expect(evidence.references).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: "off-brief-pin" }), + expect.objectContaining({ id: "off-brief-board" }) + ])); + expect(evidence.designVectors?.sourcePriority).toBe("brief-only"); + expect(evidence.referencePatternBoard?.qualitySummary).toMatchObject({ + rejectedReferenceCount: 2, + failedCaptureCount: 0, + missingScreenshotCount: 0 + }); + expect(JSON.stringify(evidence.referencePatternBoard)).not.toContain("Estilo de portal digital funcional"); + expect(JSON.stringify(evidence.referencePatternBoard)).not.toContain("Functional portal moodboard"); + expect(JSON.stringify(evidence.rankedReferences)).not.toContain("Estilo de portal digital funcional"); + expect(JSON.stringify(evidence.rankedReferences)).not.toContain("https://www.pinterest.com/pin/31525266137895345/"); + expect(JSON.stringify(packet.rankedReferences)).not.toContain("Functional portal moodboard"); + expect(packet.rankedReferences).toEqual([]); + expect(packet.designContract.intent.referenceUrls).toEqual([]); + expect(packet.advancedBriefMarkdown.indexOf("Reference evidence unavailable:")).toBe(0); + expect(JSON.stringify(packet.designContract)).not.toContain("Estilo de portal digital funcional"); + expect(JSON.stringify(packet.designContract)).not.toContain("Functional portal moodboard"); + expect(JSON.stringify(packet.generationPlan)).not.toContain("Estilo de portal digital funcional"); + expect(JSON.stringify(packet.generationPlan)).not.toContain("Functional portal moodboard"); + expect(JSON.stringify(packet.followthrough.implementationContext)).not.toContain("Estilo de portal digital funcional"); + expect(JSON.stringify(packet.followthrough.implementationContext)).not.toContain("Functional portal moodboard"); + expect(JSON.stringify(packet.implementationPlan)).not.toContain("Estilo de portal digital funcional"); + expect(JSON.stringify(packet.implementationPlan)).not.toContain("Functional portal moodboard"); + expect(packet.implementationPlanMarkdown).not.toContain("Estilo de portal digital funcional"); + expect(packet.implementationPlanMarkdown).not.toContain("Functional portal moodboard"); + expect(packet.implementationPlanMarkdown).not.toContain("31525266137895345"); + expect(packet.implementationPlanMarkdown).not.toContain("off-brief"); + expect(JSON.stringify(packet.canvasPlanRequest.generationPlan)).not.toContain("Estilo de portal digital funcional"); + expect(JSON.stringify(packet.canvasPlanRequest.generationPlan)).not.toContain("Functional portal moodboard"); + expect(packet.designMarkdown).not.toContain("Estilo de portal digital funcional"); + expect(packet.designMarkdown).not.toContain("Functional portal moodboard"); + expect(packet.metaPromptMarkdown).not.toContain("Estilo de portal digital funcional"); + expect(packet.metaPromptMarkdown).not.toContain("Functional portal moodboard"); + expect(packet.metaPromptMarkdown).toContain("No ready references were ranked."); + expect(packet.metaPromptMarkdown).toContain("ranked reference(s) were not ready for creative synthesis"); + expect(packet.metaPromptMarkdown).not.toContain("https://www.pinterest.com/pin/31525266137895345/"); }); it("classifies page, component, and asset prototype targets without changing the Canvas request shape", () => { @@ -1833,9 +2108,9 @@ describe("inspiredesign packet + renderer", () => { }); expect(packet.advancedBriefMarkdown.indexOf("Reference evidence unavailable:")).toBe(0); - expect(packet.advancedBriefMarkdown).toContain( - "https://example.com/protected: fetch=failed, capture=failed, reason=Authentication required" - ); + expect(packet.advancedBriefMarkdown).toContain("1 attempted reference(s) are retained in diagnostic artifacts only."); + expect(packet.advancedBriefMarkdown).not.toContain("https://example.com/protected"); + expect(packet.advancedBriefMarkdown).not.toContain("Authentication required"); expect(packet.advancedBriefMarkdown).toContain("Selected prompt format: Premium editorial landing page"); expect(packet.advancedBriefMarkdown).not.toContain("admin dashboard analytics"); expect(packet.implementationPlan.risksAndAmbiguities[0]).toContain( @@ -2825,7 +3100,8 @@ describe("inspiredesign packet + renderer", () => { expect(rankedReferencesFile?.content).toMatchObject({ references: [expect.objectContaining({ id: "usable-reference", rank: 1 })], - rejectedReferences: [expect.objectContaining({ id: "rejected-reference" })], + rejectedReferences: [], + qualitySummary: expect.objectContaining({ rejectedReferenceCount: 1 }), synthesis: expect.objectContaining({ dominantDirection: expect.any(String), sharedFailuresToAvoid: expect.any(Array) @@ -3012,7 +3288,8 @@ describe("inspiredesign packet + renderer", () => { brief: "Create a photography studio landing page", briefExpansion: makeBriefExpansion(), urls: ["https://example.com/blocked"], - references: [] + references: [], + includePrototypeGuidance: true }); const nextStepGuidance = { id: "inspiredesign.harvest.zero_references", @@ -3054,16 +3331,38 @@ describe("inspiredesign packet + renderer", () => { prototypeGuidanceMarkdown: packet.prototypeGuidanceMarkdown, evidence: packet.evidence, nextStepGuidance, - meta: {} + meta: { + primaryConstraintSummary: "Pinterest requires a user-authorized signed-in browser session." + } }); const response = rendered.response as Record; - const responseHandoff = response.designAgentHandoff as { commandExamples: { continueInCanvas: string } }; - const handoffFile = rendered.files.find((file) => file.path === INSPIREDESIGN_HANDOFF_FILES.designAgentHandoff)?.content as { commandExamples: { continueInCanvas: string } } | undefined; + const responseHandoff = response.designAgentHandoff as { + artifactGuide: Record; + commandExamples: { continueInCanvas: string }; + summary: string; + }; + const handoffFile = rendered.files.find((file) => file.path === INSPIREDESIGN_HANDOFF_FILES.designAgentHandoff)?.content as typeof responseHandoff | undefined; expect(responseHandoff.commandExamples.continueInCanvas).not.toContain("canvas.plan.set"); expect(responseHandoff.commandExamples.continueInCanvas).toContain("nextStepGuidance.readiness"); expect(handoffFile?.commandExamples.continueInCanvas).toBe(responseHandoff.commandExamples.continueInCanvas); + expect(responseHandoff.summary).toBe(response.followthroughSummary); + expect(responseHandoff.summary).not.toContain("continue in OpenDevBrowser Canvas"); + expect(responseHandoff.artifactGuide[INSPIREDESIGN_HANDOFF_FILES.canvasPlanRequest]?.purpose).toContain( + "Diagnostic Canvas request preview" + ); + expect(responseHandoff.artifactGuide[INSPIREDESIGN_HANDOFF_FILES.canvasPlanRequest]?.mustNot).toContain( + "Do not submit canvas.plan.set while nextStepGuidance.readiness is not ready." + ); + expect(handoffFile?.artifactGuide[INSPIREDESIGN_HANDOFF_FILES.canvasPlanRequest]?.purpose).toBe( + responseHandoff.artifactGuide[INSPIREDESIGN_HANDOFF_FILES.canvasPlanRequest]?.purpose + ); expect(response.suggestedNextAction).toBe("Collect usable reference evidence before Canvas."); + expect(response.followthroughSummary).toBe( + "Primary constraint: Pinterest requires a user-authorized signed-in browser session. Collect usable reference evidence before Canvas." + ); + expect(response.prototypeGuidanceMarkdown).toBeNull(); + expect(rendered.files.some((file) => file.path === INSPIREDESIGN_HANDOFF_FILES.prototypeGuidance)).toBe(false); const renderedWithoutGuidance = renderInspiredesign({ mode: "json", @@ -3087,6 +3386,13 @@ describe("inspiredesign packet + renderer", () => { expect(handoffWithoutGuidance.commandExamples.continueInCanvas).toBe( "Unavailable until nextStepGuidance.readiness is ready." ); + expect(responseWithoutGuidance.followthroughSummary).toBe( + "Canvas continuation unavailable until nextStepGuidance.readiness is ready." + ); + expect(responseWithoutGuidance.suggestedNextAction).toBe( + "Canvas continuation unavailable until nextStepGuidance.readiness is ready." + ); + expect(JSON.stringify(responseWithoutGuidance.designAgentHandoff)).not.toContain("continue in OpenDevBrowser Canvas"); }); it.each([ @@ -3148,6 +3454,52 @@ describe("inspiredesign packet + renderer", () => { expect(responseHandoff.commandExamples.continueInCanvas).toBe("Unavailable until nextStepGuidance.readiness is ready."); }); + it("does not label Canvas request artifacts as ready when attempted references are diagnostic-only", () => { + const packet = buildInspiredesignPacket({ + brief: "Create a cinematic design agency studio landing page", + briefExpansion: makeBriefExpansion(), + urls: ["https://www.pinterest.com/pin/11188699075430754/"], + references: [ + makeReference({ + id: "diagnostic-pin", + url: "https://www.pinterest.com/pin/11188699075430754/", + title: "Pinterest navigation shell", + fetchStatus: "failed", + captureStatus: "captured", + fetchFailure: "Pinterest requires browser-native follow-up", + capture: { + status: "captured", + kind: "viewport", + path: "visual-evidence/diagnostic-pin/viewport.png", + sha256: "a".repeat(64), + bytes: 64000 + } + }) + ] + }); + + expect(packet.rankedReferences).toEqual([]); + expect(packet.designMarkdown).toContain( + "Diagnostic `canvasPlanRequest` preview; do not submit to Canvas until next-step guidance is ready" + ); + expect(packet.designMarkdown).not.toContain("Ready-to-fill `canvasPlanRequest` JSON for `canvas.plan.set`"); + + const zeroReferenceHarvestPacket = buildInspiredesignPacket({ + brief: "Create a cinematic design agency studio landing page", + briefExpansion: makeBriefExpansion(), + urls: [], + references: [], + referenceEvidenceRequired: true + }); + + expect(zeroReferenceHarvestPacket.designMarkdown).toContain( + "Diagnostic `canvasPlanRequest` preview; do not submit to Canvas until next-step guidance is ready" + ); + expect(zeroReferenceHarvestPacket.designMarkdown).not.toContain( + "Ready-to-fill `canvasPlanRequest` JSON for `canvas.plan.set`" + ); + }); + it("handles sparse capture evidence, empty task summaries, and truncated excerpts", () => { const longExcerpt = "Quiet editorial cards with disciplined spacing and strong CTA focus. ".repeat(6).trim(); const expectedExcerpt = `${longExcerpt.slice(0, 217).trimEnd()}...`; diff --git a/tests/providers-inspiredesign-workflow.test.ts b/tests/providers-inspiredesign-workflow.test.ts index b8296d7..cc29443 100644 --- a/tests/providers-inspiredesign-workflow.test.ts +++ b/tests/providers-inspiredesign-workflow.test.ts @@ -502,7 +502,7 @@ describe("inspiredesign workflow", () => { includePrototypeGuidance: true }, { captureReference: async (url: string) => ({ - ...makeCapture(`Atelier Luma Studio limestone hero brass CTA rail staggered project index from ${url}`), + ...makeCapture(`Premium launch surface for Atelier Luma Studio with limestone hero brass CTA rail staggered project index from ${url}`), attempts: { snapshot: { status: "captured" }, clone: { status: "captured" }, @@ -1442,6 +1442,7 @@ describe("inspiredesign workflow", () => { 1, { url: "https://www.pinterest.com/search/pins/?q=premium+photography+studio+landing+page" }, expect.objectContaining({ + source: "web", runtimePolicy: expect.objectContaining({ browserMode: "managed", useCookies: false @@ -1506,6 +1507,7 @@ describe("inspiredesign workflow", () => { 1, { url: "https://www.pinterest.com/search/pins/?q=premium+photography+studio+landing+page" }, expect.objectContaining({ + source: "web", runtimePolicy: expect.objectContaining({ browserMode: "extension", useCookies: true, @@ -1573,9 +1575,25 @@ describe("inspiredesign workflow", () => { { query: "premium photography studio landing page", limit: 5 }, expect.objectContaining({ providerIds: ["web/default"] }) ); - expect(fetch).toHaveBeenCalledWith( + expect(fetch).toHaveBeenNthCalledWith( + 1, { url: "https://www.pinterest.com/search/pins/?q=premium+photography+studio+landing+page" }, - expect.any(Object) + expect.objectContaining({ source: "web" }) + ); + expect(fetch.mock.calls[0]?.[1]).not.toHaveProperty("providerIds"); + expect(fetch).toHaveBeenCalledWith( + { url: "https://www.pinterest.com/pin/61572719900827789/" }, + expect.objectContaining({ source: "web" }) + ); + const pinterestPinFetchOptions = fetch.mock.calls.find(([input]) => ( + input.url === "https://www.pinterest.com/pin/61572719900827789/" + ))?.[1]; + expect(pinterestPinFetchOptions).not.toHaveProperty("providerIds"); + expect(fetch).toHaveBeenCalledWith( + { url: "https://example.com/studio-reference" }, + expect.objectContaining({ + providerIds: ["web/default"] + }) ); expect(meta.discovery).toEqual(expect.objectContaining({ siteRecipeId: "social/pinterest", @@ -1594,6 +1612,68 @@ describe("inspiredesign workflow", () => { })); }); + it("blocks Canvas continuation when a mixed Pinterest lane has a hard auth failure", async () => { + const search = vi.fn(async () => makeAggregate({ + records: [ + normalizeRecord("web/default", "web", { + url: "https://example.com/studio-reference", + title: "Photography studio landing page", + content: "A premium photography studio landing page with cinematic portrait imagery, parallax sections, booking CTA, and editorial motion cues." + }) + ] + })); + const fetch = vi.fn(async (input: { url: string }) => { + if (input.url.includes("/search/pins/")) { + return makeAggregate({ + ok: false, + records: [], + failures: [ + makeFailure("social/pinterest", "social", { + code: "auth", + message: "Pinterest requires login.", + reasonCode: "auth_required" + }) + ] + }); + } + return makeAggregate({ + records: [ + normalizeRecord("web/default", "web", { + url: input.url, + title: "Fetched studio reference", + content: "A premium photography studio landing page with cinematic portrait imagery, parallax sections, booking CTA, and editorial motion cues." + }) + ] + }); + }); + const captureReference = vi.fn(async (url: string) => makeCapture(`Captured ${url}`)); + + const output = await runInspiredesignWorkflow(toRuntime({ fetch, search }), { + brief: "Design a cinematic photography studio landing page", + harvest: true, + query: "premium photography studio landing page", + providers: ["web/default", "social/pinterest"], + visualEvidence: "off", + mode: "json" + }, { + captureReference + }); + + const meta = output.meta as InspiredesignWorkflowMeta; + expect(meta.discovery).toEqual(expect.objectContaining({ + siteRecipeId: "social/pinterest", + acceptedUrls: ["https://example.com/studio-reference"] + })); + expect(meta.metrics.reasonCodeDistribution).toEqual(expect.objectContaining({ + auth_required: 1 + })); + expect(meta.nextStepGuidance).toEqual(expect.objectContaining({ + readiness: "blocked", + reasonCode: "provider_unavailable" + })); + expect(meta.nextStepGuidance?.commands.map((command) => command.command).join("\n")).not.toContain("canvas.plan.set"); + }); + it("keeps standard provider search first when Pinterest appears before web in a mixed provider harvest", async () => { const search = vi.fn(async () => makeAggregate({ records: [ @@ -1644,10 +1724,20 @@ describe("inspiredesign workflow", () => { { query: "premium photography studio landing page", limit: 5 }, expect.objectContaining({ providerIds: ["web/default"] }) ); - expect(fetch).toHaveBeenCalledWith( + expect(fetch).toHaveBeenNthCalledWith( + 1, { url: "https://www.pinterest.com/search/pins/?q=premium+photography+studio+landing+page" }, - expect.any(Object) + expect.objectContaining({ source: "web" }) ); + expect(fetch.mock.calls[0]?.[1]).not.toHaveProperty("providerIds"); + expect(fetch).toHaveBeenCalledWith( + { url: "https://www.pinterest.com/pin/61572719900827790/" }, + expect.objectContaining({ source: "web" }) + ); + const reversePinterestPinFetchOptions = fetch.mock.calls.find(([input]) => ( + input.url === "https://www.pinterest.com/pin/61572719900827790/" + ))?.[1]; + expect(reversePinterestPinFetchOptions).not.toHaveProperty("providerIds"); expect(meta.discovery).toEqual(expect.objectContaining({ siteRecipeId: "social/pinterest", acceptedUrls: ["https://www.pinterest.com/pin/61572719900827790/", "https://example.com/reverse-studio-reference"] @@ -1731,6 +1821,129 @@ describe("inspiredesign workflow", () => { ); }); + it("keeps a standard provider reference when Pinterest fills the mixed provider limit", async () => { + const search = vi.fn(async () => makeAggregate({ + records: [ + normalizeRecord("web/default", "web", { + url: "https://example.com/standard-preserved", + title: "Standard provider preserved", + content: "A premium photography studio landing page with cinematic portrait imagery, booking CTA, and editorial motion cues." + }) + ] + })); + const fetch = vi.fn(async (input: { url: string }) => { + if (input.url.includes("/search/pins/")) { + return makeAggregate({ + records: [ + normalizeRecord("social/pinterest", "social", { + url: input.url, + title: "Pinterest cap preservation results", + content: [ + 'First cap studio pin', + 'Second cap studio pin' + ].join("") + }) + ] + }); + } + return makeAggregate({ + records: [ + normalizeRecord("web/default", "web", { + url: input.url, + title: "Fetched cap fairness reference", + content: "A premium photography studio landing page with cinematic portrait imagery, booking CTA, and editorial motion cues." + }) + ] + }); + }); + const captureReference = vi.fn(async (url: string) => makeCapture(`Captured ${url}`)); + + const output = await runInspiredesignWorkflow(toRuntime({ fetch, search }), { + brief: "Design a cinematic photography studio landing page", + harvest: true, + query: "premium photography studio landing page", + providers: ["web/default", "social/pinterest"], + maxReferences: 2, + visualEvidence: "off", + mode: "json" + }, { + captureReference + }); + + const meta = output.meta as InspiredesignWorkflowMeta; + expect(meta.discovery?.acceptedUrls).toEqual([ + "https://www.pinterest.com/pin/61572719900827792/", + "https://example.com/standard-preserved" + ]); + expect(meta.selection.urls).toEqual([ + "https://www.pinterest.com/pin/61572719900827792/", + "https://example.com/standard-preserved" + ]); + expect(captureReference).not.toHaveBeenCalledWith( + "https://www.pinterest.com/pin/61572719900827793/", + expect.any(Object) + ); + }); + + it("keeps both mixed provider lanes when duplicate URLs appear before unique references", async () => { + const duplicateUrl = "https://www.pinterest.com/pin/61572719900827794/"; + const search = vi.fn(async () => makeAggregate({ + records: [ + normalizeRecord("web/default", "web", { + url: duplicateUrl, + title: "Duplicate standard URL", + content: "A duplicate reference that should not consume the standard provider lane." + }), + normalizeRecord("web/default", "web", { + url: "https://example.com/standard-unique-after-duplicate", + title: "Unique standard reference", + content: "A premium photography studio landing page with cinematic portrait imagery, booking CTA, and editorial motion cues." + }) + ] + })); + const fetch = vi.fn(async (input: { url: string }) => { + if (input.url.includes("/search/pins/")) { + return makeAggregate({ + records: [ + normalizeRecord("social/pinterest", "social", { + url: input.url, + title: "Pinterest duplicate fairness results", + content: [ + 'Duplicate studio pin', + 'Second studio pin' + ].join("") + }) + ] + }); + } + return makeAggregate({ + records: [ + normalizeRecord("web/default", "web", { + url: input.url, + title: "Fetched duplicate fairness reference", + content: "A premium photography studio landing page with cinematic portrait imagery, booking CTA, and editorial motion cues." + }) + ] + }); + }); + + const output = await runInspiredesignWorkflow(toRuntime({ fetch, search }), { + brief: "Design a cinematic photography studio landing page", + harvest: true, + query: "premium photography studio landing page", + providers: ["web/default", "social/pinterest"], + maxReferences: 2, + visualEvidence: "off", + mode: "json" + }); + + const meta = output.meta as InspiredesignWorkflowMeta; + expect(meta.selection.urls).toEqual([ + duplicateUrl, + "https://example.com/standard-unique-after-duplicate" + ]); + }); + it("keeps generic recovery when mixed provider search is unavailable", async () => { const fetch = vi.fn(async () => makeAggregate({ records: [ @@ -1816,6 +2029,161 @@ describe("inspiredesign workflow", () => { })); }); + it("attributes failed Pinterest reference fetches to the site recipe provider", async () => { + const pinUrl = "https://www.pinterest.com/pin/61572719900827796/"; + const fetch = vi.fn(async (input: { url: string }) => { + if (input.url.includes("/search/pins/")) { + return makeAggregate({ + records: [ + normalizeRecord("social/pinterest", "social", { + url: input.url, + title: "Pinterest reference fetch attribution results", + content: 'Studio pin' + }) + ] + }); + } + return makeAggregate({ + ok: false, + records: [], + providerOrder: ["web/default"], + error: { + code: "unavailable", + message: "generic web fetch could not render Pinterest pin", + retryable: true, + reasonCode: "env_limited" + } + }); + }); + + const output = await runInspiredesignWorkflow(toRuntime({ fetch }), { + brief: "Design a cinematic photography studio landing page", + harvest: true, + query: "premium photography studio landing page", + providers: ["social/pinterest"], + browserMode: "extension", + useCookies: true, + visualEvidence: "off", + mode: "json" + }); + + const meta = output.meta as InspiredesignWorkflowMeta; + expect(meta.selection.urls).toEqual([pinUrl]); + expect(meta.primaryConstraint).toEqual(expect.objectContaining({ + provider: "social/pinterest", + summary: "Pinterest requires manual browser follow-up; this run did not determine whether login or page rendering is required." + })); + expect(meta.primaryConstraintSummary).toBe( + "Pinterest requires manual browser follow-up; this run did not determine whether login or page rendering is required." + ); + }); + + it("keeps real Pinterest diagnostic harvest artifacts blocked when pins have screenshots but no design-ready references", async () => { + const outputDir = makeOutputDir(); + const pinUrls = [ + "https://www.pinterest.com/pin/27654985208435505/", + "https://www.pinterest.com/pin/8022105583048554/", + "https://www.pinterest.com/pin/31525266137895345/", + "https://www.pinterest.com/pin/11188699075430754/", + "https://www.pinterest.com/pin/14355292557606825/" + ]; + const fetch = vi.fn(async (input: { url: string }) => { + if (input.url.includes("/search/pins/")) { + return makeAggregate({ + records: [ + normalizeRecord("social/pinterest", "social", { + url: input.url, + title: "Pinterest visual search shell", + content: pinUrls.map((url) => `Pin card`).join("\n") + }) + ] + }); + } + return makeAggregate({ + records: [ + normalizeRecord("social/pinterest", "social", { + url: input.url, + title: "Pinterest navigation shell", + content: "Pin card your profile when autocomplete results are available" + }) + ] + }); + }); + const captureReference = vi.fn(async (_url: string, options) => { + if (!options?.visualEvidencePath) { + throw new Error("visual evidence path missing"); + } + writeFileSync(options.visualEvidencePath, Buffer.from("png bytes")); + return { + ...makeCapture("Pin card your profile when autocomplete results are available"), + visual: { + status: "captured", + kind: "viewport", + fullPage: false, + capturedAt: "2026-05-21T00:00:00.000Z", + tempPath: options.visualEvidencePath, + warnings: [] + } + }; + }); + + const output = await runInspiredesignWorkflow(toRuntime({ fetch }), { + brief: "Design a prototype landing page for a design agency studio with premium cinematic visual direction", + harvest: true, + query: "Pinterest premium design agency studio landing page cinematic 3D parallax portfolio", + providers: ["social/pinterest"], + browserMode: "extension", + useCookies: true, + cookiePolicyOverride: "required", + referenceLimit: 5, + visualEvidence: "required", + outputDir, + mode: "path" + }, { + captureReference + }); + + const meta = output.meta as InspiredesignWorkflowMeta; + const artifactPath = String(output.artifact_path); + const rankedReferences = JSON.parse(readFileSync(join(artifactPath, "ranked-references.json"), "utf8")) as { + qualitySummary: { rankedReferenceCount: number; rejectedReferenceCount: number; missingScreenshotCount: number }; + references: unknown[]; + }; + const screenshotIndex = JSON.parse(readFileSync(join(artifactPath, "screenshot-index.json"), "utf8")) as { + screenshots: Array<{ path: string }>; + }; + const designMarkdown = readFileSync(join(artifactPath, "design.md"), "utf8"); + const handoff = JSON.parse(readFileSync(join(artifactPath, "design-agent-handoff.json"), "utf8")) as { + commandExamples: { continueInCanvas: string }; + nextStepGuidance: NextStepGuidance; + }; + + expect(meta.discovery).toEqual(expect.objectContaining({ + siteRecipeId: "social/pinterest", + acceptedUrls: pinUrls, + browserNativeDiagnostics: expect.objectContaining({ extractedUrlCount: 5 }) + })); + expect(meta.nextStepGuidance).toEqual(expect.objectContaining({ + readiness: "diagnostic_only", + reasonCode: "pinterest_browser_native_recovery" + })); + expect(rankedReferences).toMatchObject({ + qualitySummary: { + rankedReferenceCount: 0, + rejectedReferenceCount: 5, + missingScreenshotCount: 0 + }, + references: [] + }); + expect(screenshotIndex.screenshots).toHaveLength(5); + expect(designMarkdown).toContain( + "Diagnostic `canvasPlanRequest` preview; do not submit to Canvas until next-step guidance is ready" + ); + expect(designMarkdown).not.toContain("Ready-to-fill `canvasPlanRequest` JSON for `canvas.plan.set`"); + expect(handoff.commandExamples.continueInCanvas).toBe("Unavailable until nextStepGuidance.readiness is ready."); + expect(handoff.nextStepGuidance.readiness).toBe("diagnostic_only"); + }); + it("preserves generic recovery when mixed standard provider search throws", async () => { const search = vi.fn(async () => { throw new Error("standard provider search failed"); @@ -1901,6 +2269,64 @@ describe("inspiredesign workflow", () => { })); }); + it("keeps Pinterest browser-native auth failures blocking when only unrelated explicit references succeed", async () => { + const fetch = vi.fn(async (input: { url: string }) => { + if (input.url.includes("/search/pins/")) { + return makeAggregate({ + ok: false, + records: [], + failures: [ + makeFailure("social/pinterest", "social", { + code: "auth", + message: "Pinterest requires login.", + reasonCode: "auth_required" + }) + ] + }); + } + return makeAggregate({ + records: [ + normalizeRecord("web/default", "web", { + url: input.url, + title: "Explicit unrelated reference", + content: "Premium ceramic coffee roaster landing page with warm product photography, editorial hero rhythm, and conversion CTA." + }) + ] + }); + }); + const captureReference = vi.fn(async (url: string) => makeCapture(`Captured ${url}`)); + + const output = await runInspiredesignWorkflow(toRuntime({ fetch }), { + brief: "Design a coffee roaster landing page", + harvest: true, + query: "premium ceramic coffee roaster landing page design", + providers: ["social/pinterest"], + urls: ["https://example.com/coffee-roaster-reference"], + browserMode: "managed", + useCookies: false, + cookiePolicyOverride: "required", + visualEvidence: "off", + mode: "json" + }, { + captureReference + }); + + const meta = output.meta as InspiredesignWorkflowMeta; + expect(meta.metrics).toEqual(expect.objectContaining({ + reference_count: 1, + fetched_references: 1, + captured_references: 1 + })); + expect(meta.metrics.reasonCodeDistribution).toEqual(expect.objectContaining({ + auth_required: 1 + })); + expect(meta.nextStepGuidance).toEqual(expect.objectContaining({ + readiness: "blocked", + reasonCode: "pinterest_browser_native_recovery" + })); + expect(meta.nextStepGuidance?.commands.map((command) => command.command).join("\n")).not.toContain("canvas.plan.set"); + }); + it("does not cap explicit non-harvest URLs when visual evidence is enabled", async () => { const urls = Array.from({ length: 11 }, (_, index) => `https://example.com/reference-${index + 1}`); const fetch = vi.fn(async (input: { url: string }) => makeAggregate({ @@ -2916,7 +3342,7 @@ describe("inspiredesign workflow", () => { reasonCodeDistribution: { env_limited: 1 }, - primaryConstraintSummary: "Default requires a live browser-rendered page.", + primaryConstraintSummary: "Pinterest requires a live browser-rendered page.", primaryConstraint: expect.objectContaining({ reasonCode: "env_limited", constraint: expect.objectContaining({ @@ -3185,7 +3611,7 @@ describe("inspiredesign workflow", () => { reasonCodeDistribution: { env_limited: 1 }, - primaryConstraintSummary: "Default requires a live browser-rendered page." + primaryConstraintSummary: "Pinterest requires a live browser-rendered page." }); });