Skip to content

Commit d1d9da5

Browse files
committed
No-false-results: Zod schema validation + a11y/mobile pass
Three landing pieces: 1. Schema-validated LLM outputs (the "no false results" guarantee) - New lib/llm-structured.ts: llmCallStructured() wraps any LLM call with Zod schema validation + one automatic retry-on-fail that re-prompts the same model with the validation errors so it can self-correct. Throws StructuredLlmError on second failure rather than letting bad shapes crash the renderer. - GeneratorShell now honors an optional schema on GeneratorConfig. When the streamed JSON fails Zod parse, the shell sends one non-streaming corrective round-trip behind a "ai output auto-corrected" badge. If that also fails, the user sees an actionable error instead of a UI crash from undefined.map(). - Schemas wired for the three highest-traffic structured generators: lib/schemas/creative-score.ts lib/schemas/content-calendar.ts lib/schemas/campaign-kit.ts - 10 new vitest cases cover clean parse, fenced JSON, wrong-shape retry success/fail, total non-JSON retry, maxAttempts=1. 2. /research/competitors honesty - The LLM can't fetch live ads; previously, submitting just a competitor name produced plausible-but-invented teardowns. - Form now requires >= 120 chars of pasted ad copy and shows an inline "the AI has no live access to ad libraries" note. - Prompt has a CRITICAL clause: if the paste block is empty or trivial, emit an explanatory error JSON instead of inventing ads. - Renderer detects that error JSON and shows a clear nudge to use the Meta Ads Library / Google Transparency / TikTok Top Ads / LinkedIn Ad Library buttons above. 3. Accessibility + mobile pass (from punch-list audit) - Skip-to-content link in app/layout.tsx - MobileNav: h-11 hamburger + close, role="dialog" + aria-modal, Esc handler, focus moved into drawer on open + returned to trigger on close - StatusBar: flex-wrap so 8-cell row reflows under 380px - CopyButton: py-2 mobile / py-1 desktop (44px touch), aria-live so screen readers announce "copied" - GeneratorShell FieldRenderer: stable inputId + htmlFor on every label, aria-required on required fields — fixes a11y across ALL generators in one place - File input now has aria-label - Stop buttons in GeneratorShell, competitors page, launch wizard all gained aria-label="Stop ..." - Settings ToggleRow: role="switch" + aria-checked - CommandPalette: tabIndex=-1 on option buttons traps Tab focus on the search input so keyboard users can't escape behind the modal Build: tsc clean, 53/53 unit tests pass, next build clean (76 routes, +0 kB on shared chunks, +5 kB on competitors due to honesty branch).
1 parent 25ea4a9 commit d1d9da5

22 files changed

Lines changed: 761 additions & 27 deletions

File tree

app/generate/campaign-kit/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { GeneratorShell } from "@/components/GeneratorShell";
44
import { Section, Pill } from "@/components/OutputBlocks";
55
import { CopyButton } from "@/components/CopyButton";
66
import { buildCampaignKitPrompt, type CampaignKitInput } from "@/lib/prompts/campaign-kit";
7+
import { CampaignKitSchema } from "@/lib/schemas/campaign-kit";
78
import type { GeneratorConfig } from "@/lib/generator-config";
89

910
const config: GeneratorConfig<CampaignKitInput & Record<string, unknown>> = {
@@ -25,6 +26,7 @@ const config: GeneratorConfig<CampaignKitInput & Record<string, unknown>> = {
2526
buildPrompt: (input) => buildCampaignKitPrompt(input as unknown as CampaignKitInput),
2627
buildTitle: (i: any) => `Kit · ${i.campaign_name}`,
2728
expectJson: true,
29+
schema: CampaignKitSchema,
2830
renderJson: (json) => <KitOutput json={json} />,
2931
};
3032

app/generate/content-calendar/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Section, Pill } from "@/components/OutputBlocks";
55
import { CopyButton } from "@/components/CopyButton";
66
import { ExternalLink } from "lucide-react";
77
import { buildContentCalendarPrompt, type ContentCalendarInput } from "@/lib/prompts/content-calendar";
8+
import { ContentCalendarSchema } from "@/lib/schemas/content-calendar";
89
import { IMAGE_TOOLS, VIDEO_TOOLS } from "@/lib/prompts/creative-prompts";
910
import { safeHref } from "@/lib/utils";
1011
import type { GeneratorConfig } from "@/lib/generator-config";
@@ -48,6 +49,7 @@ const config: GeneratorConfig<ContentCalendarInput & Record<string, unknown>> =
4849
buildPrompt: (input) => buildContentCalendarPrompt(input as unknown as ContentCalendarInput),
4950
buildTitle: (i: any) => `Calendar · ${i.duration} · ${i.platforms?.slice(0, 24)}`,
5051
expectJson: true,
52+
schema: ContentCalendarSchema,
5153
renderJson: (json) => <CalendarOutput json={json} />,
5254
};
5355

app/launch/wizard/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -686,7 +686,7 @@ function Inner() {
686686
{running ? "Building your launch kit…" : "Build it · 10-min launch kit"}
687687
</button>
688688
{running ? (
689-
<button onClick={stopWizard} className="btn-ghost" title="Stop the wizard — finished phases stay saved">
689+
<button onClick={stopWizard} className="btn-ghost" title="Stop the wizard — finished phases stay saved" aria-label="Stop wizard">
690690
<StopCircle size={12} />
691691
</button>
692692
) : null}

app/layout.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,14 +67,22 @@ export default function RootLayout({ children }: { children: React.ReactNode })
6767
return (
6868
<html lang="en" className={`dark ${sans.variable} ${mono.variable} ${display.variable}`}>
6969
<body className="font-sans antialiased">
70+
{/* Skip-to-content for keyboard + screen-reader users — visually
71+
hidden until focused, then jumps past the sidebar / mobile nav. */}
72+
<a
73+
href="#main-content"
74+
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[100] focus:bg-base-900 focus:text-ink focus:px-3 focus:py-2 focus:border focus:border-live"
75+
>
76+
Skip to main content
77+
</a>
7078
<div className="flex min-h-screen">
7179
<Sidebar />
7280
<MobileNav />
7381
<ServiceWorkerRegister />
7482
<LocalSyncBoot />
7583
<CommandPalette />
7684
<UndoToast />
77-
<main className="flex-1 min-w-0 flex flex-col">
85+
<main id="main-content" className="flex-1 min-w-0 flex flex-col">
7886
<div className="flex-1 px-4 md:px-10 pt-14 md:pt-6 pb-14">{children}</div>
7987
<StatusBar />
8088
</main>

app/optimize/creative-score/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { GeneratorShell } from "@/components/GeneratorShell";
44
import { Section, Pill, ScoreBar } from "@/components/OutputBlocks";
55
import { CopyButton } from "@/components/CopyButton";
66
import { buildCreativeScorePrompt, type CreativeScoreInput } from "@/lib/prompts/creative-score";
7+
import { CreativeScoreSchema } from "@/lib/schemas/creative-score";
78
import type { GeneratorConfig } from "@/lib/generator-config";
89

910
const config: GeneratorConfig<CreativeScoreInput & Record<string, unknown>> = {
@@ -37,6 +38,7 @@ const config: GeneratorConfig<CreativeScoreInput & Record<string, unknown>> = {
3738
buildPrompt: (input) => buildCreativeScorePrompt(input as unknown as CreativeScoreInput),
3839
buildTitle: (i: any) => `Score · ${i.platform}`,
3940
expectJson: true,
41+
schema: CreativeScoreSchema,
4042
renderJson: (json) => <ScoreOutput json={json} />,
4143
};
4244

app/research/competitors/page.tsx

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,17 @@ function Inner() {
102102
setSavedId(null);
103103
setParsed(null);
104104
stream.reset();
105-
if (!input.competitor_ads_pasted.trim()) {
106-
setError("Paste at least one competitor ad from the libraries above.");
105+
// Hard guardrail against hallucinated teardowns: the LLM does NOT have live
106+
// ad data. If the user submits just a brand name + empty paste, the model
107+
// would invent plausible-looking ads. Require enough pasted content that
108+
// the analysis is actually grounded in real copy. 120 chars ≈ one short ad.
109+
const pasted = input.competitor_ads_pasted.trim();
110+
if (pasted.length < 120) {
111+
setError(
112+
pasted.length === 0
113+
? "Paste real competitor ad copy from the libraries above before running. We do not fetch ads for you — the AI only analyzes what you paste."
114+
: "Paste more competitor ad copy (at least ~120 chars / one full ad). Without real ad text, the teardown would be guesswork."
115+
);
107116
return;
108117
}
109118
if (!input.our_product.trim() || !input.our_usp.trim()) {
@@ -244,6 +253,11 @@ function Inner() {
244253
</div>
245254
<div>
246255
<label className="label">competitor ads · pasted *</label>
256+
<p className="text-[11px] text-ink-muted leading-relaxed mb-1.5">
257+
<span className="text-live font-medium">Honesty note:</span> the AI has <em>no live access</em> to ad libraries.
258+
Open the libraries above, copy the ads you want analyzed, paste them here. If you paste a brand name only,
259+
the AI will refuse rather than invent ads.
260+
</p>
247261
<textarea
248262
rows={8}
249263
className="input-base font-mono text-xs"
@@ -267,7 +281,7 @@ function Inner() {
267281
{running ? <Loader2 size={12} className="animate-spin" /> : <Sparkles size={12} />}
268282
{running ? "analyzing" : "teardown + beat"}
269283
</button>
270-
{running ? <button onClick={() => abortRef.current?.abort()} className="btn-ghost"><StopCircle size={12} /></button> : null}
284+
{running ? <button onClick={() => abortRef.current?.abort()} className="btn-ghost" aria-label="Stop teardown"><StopCircle size={12} /></button> : null}
271285
</div>
272286
{savedId ? (
273287
<div className="text-[10px] text-pos flex items-center gap-1.5 font-mono uppercase tracking-ui-mega">
@@ -297,6 +311,24 @@ function Inner() {
297311
}
298312

299313
function StealOutput({ json }: { json: any }) {
314+
// Honesty path: when the prompt's CRITICAL rule fires (empty/insufficient
315+
// paste), the model returns an explanatory error string instead of inventing
316+
// teardowns. Surface that clearly so the user knows they need to paste real
317+
// ad copy — they didn't pay tokens for hallucinated competitor data.
318+
if (json?.error && !json?.teardown?.length) {
319+
return (
320+
<div className="border border-live/40 bg-live/[0.04] p-5 space-y-3 animate-fade-up">
321+
<div className="text-[10px] font-mono uppercase tracking-ui-mega text-live flex items-center gap-2">
322+
<span className="h-1 w-1 bg-live" /> nothing to analyze
323+
</div>
324+
<p className="text-sm text-ink leading-relaxed">{json.error}</p>
325+
<p className="text-[11px] text-ink-muted">
326+
Use the library buttons above (Meta Ads Library, Google Ads Transparency, TikTok Top Ads, LinkedIn Ad Library),
327+
copy real ads, paste them in the textarea, and re-run.
328+
</p>
329+
</div>
330+
);
331+
}
300332
return (
301333
<div className="space-y-4 stagger">
302334
{json?.teardown?.length ? (

app/settings/page.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -399,12 +399,18 @@ function SettingsInner() {
399399

400400
function ToggleRow({ label, desc, v, on }: { label: string; desc: string; v: boolean; on: (next: boolean) => void }) {
401401
return (
402-
<button onClick={() => on(!v)} className="w-full flex items-center gap-3 text-left border border-base-700 px-3 py-2 hover:bg-base-800/40 transition">
402+
<button
403+
role="switch"
404+
aria-checked={v}
405+
aria-label={label}
406+
onClick={() => on(!v)}
407+
className="w-full flex items-center gap-3 text-left border border-base-700 px-3 py-2 hover:bg-base-800/40 transition"
408+
>
403409
<div className="flex-1">
404410
<div className="text-[12px] text-ink">{label}</div>
405411
<div className="text-[10px] font-mono uppercase tracking-ui-wide text-ink-subtle mt-0.5">{desc}</div>
406412
</div>
407-
<span className={`h-5 w-9 border relative ${v ? "border-live bg-live/20" : "border-base-500 bg-base-900"}`}>
413+
<span className={`h-5 w-9 border relative ${v ? "border-live bg-live/20" : "border-base-500 bg-base-900"}`} aria-hidden="true">
408414
<span className={`absolute top-0.5 h-3.5 w-3.5 transition ${v ? "left-[18px] bg-live" : "left-0.5 bg-base-500"}`} />
409415
</span>
410416
</button>

components/CommandPalette.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,10 @@ export function CommandPalette() {
265265
aria-selected={isActive}
266266
onMouseEnter={() => setIdx(myIdx)}
267267
onClick={() => a.run()}
268+
// tabIndex=-1 traps Tab focus on the search input so
269+
// keyboard users can't escape the modal to the page
270+
// behind it. Arrow keys + Enter already drive selection.
271+
tabIndex={-1}
268272
className={`w-full text-left flex items-center gap-3 px-4 py-2 text-[14px] ${
269273
isActive ? "bg-base-800 text-ink" : "text-ink-muted hover:bg-base-800/50"
270274
}`}

components/CopyButton.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ export function CopyButton({
1717
return (
1818
<button
1919
type="button"
20+
aria-live="polite"
21+
aria-label={copied ? `${label} — copied` : `${label} to clipboard`}
2022
onClick={async () => {
2123
try {
2224
await navigator.clipboard.writeText(text);
@@ -25,7 +27,9 @@ export function CopyButton({
2527
} catch {}
2628
}}
2729
className={cn(
28-
"inline-flex items-center gap-1.5 border border-base-600 bg-base-900/60 hover:bg-base-800 px-2 py-1 text-[10px] font-mono uppercase tracking-ui-wide text-ink-muted hover:text-ink",
30+
// py-2 mobile / py-1 desktop hits 44px tap target on touch devices
31+
// without bloating dense desktop layouts.
32+
"inline-flex items-center gap-1.5 border border-base-600 bg-base-900/60 hover:bg-base-800 px-2 py-2 md:py-1 text-[10px] font-mono uppercase tracking-ui-wide text-ink-muted hover:text-ink",
2933
className
3034
)}
3135
>

0 commit comments

Comments
 (0)