From 3c927dea48a3d6d3c81ecdf4f00a6e673e8542fa Mon Sep 17 00:00:00 2001 From: Benjamin Fenton <270411513+fentonbenjamin@users.noreply.github.com> Date: Fri, 27 Mar 2026 09:32:57 -0400 Subject: [PATCH 01/15] Add staggered reveal animation for shape results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Badges → title → spine lines fade in sequentially, giving the output a composed-not-dumped feel. Co-Authored-By: Claude Opus 4.6 (1M context) --- components/shape-result.tsx | 40 ++++++++++++++++++++++++++++++++----- next-env.d.ts | 2 +- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/components/shape-result.tsx b/components/shape-result.tsx index 731fb7d..1d96020 100644 --- a/components/shape-result.tsx +++ b/components/shape-result.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import type { ShapeResult, NarrativeSegment, ConceptBlob, SupportMap } from "@/lib/types"; import { CastView } from "./cast-view"; import { CheckView } from "./check-view"; @@ -84,11 +84,34 @@ function ConceptView({ output }: { output: ConceptBlob }) { export function ShapeResult({ result }: { result: ShapeResult }) { const [tab, setTab] = useState<"readable" | "json" | "card">("readable"); const [showFull, setShowFull] = useState(false); + const [visibleSpine, setVisibleSpine] = useState(0); + const [titleVisible, setTitleVisible] = useState(false); + const [badgesVisible, setBadgesVisible] = useState(false); const { profile, spine, output, support, casts, check } = result; + // Staggered reveal: badges → title → spine lines one by one + useEffect(() => { + setVisibleSpine(0); + setTitleVisible(false); + setBadgesVisible(false); + + const t0 = setTimeout(() => setBadgesVisible(true), 100); + const t1 = setTimeout(() => setTitleVisible(true), 350); + + const spineTimers = spine.map((_, i) => + setTimeout(() => setVisibleSpine(i + 1), 600 + i * 400) + ); + + return () => { + clearTimeout(t0); + clearTimeout(t1); + spineTimers.forEach(clearTimeout); + }; + }, [result, spine]); + return (
-
+
{profile === "narrative_segment_v0" ? "narrative" : "concept"} @@ -106,13 +129,20 @@ export function ShapeResult({ result }: { result: ShapeResult }) { )}
-

{output.title}

+

+ {output.title} +

- {/* Spine — the primary value surface */} + {/* Spine — staggered reveal */} {spine.length > 0 && (
{spine.map((s, i) => ( -

+

{s}

))} diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From 506677b75dfa1347af480505bafc5bc4fc043883 Mon Sep 17 00:00:00 2001 From: Benjamin Fenton <270411513+fentonbenjamin@users.noreply.github.com> Date: Fri, 27 Mar 2026 10:36:36 -0400 Subject: [PATCH 02/15] Lazy-init OpenAI client to fix Vercel build Client was crashing at import time when OPENAI_API_KEY wasn't available during static page collection. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/model.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/model.ts b/lib/model.ts index b5ed588..3ec93db 100644 --- a/lib/model.ts +++ b/lib/model.ts @@ -1,6 +1,10 @@ import OpenAI from "openai"; -const client = new OpenAI(); +let _client: OpenAI | null = null; +function getClient() { + if (!_client) _client = new OpenAI(); + return _client; +} export async function runShapePrompt({ systemPrompt, @@ -9,7 +13,7 @@ export async function runShapePrompt({ systemPrompt: string; userText: string; }): Promise { - const response = await client.chat.completions.create({ + const response = await getClient().chat.completions.create({ model: "gpt-4.1-mini", temperature: 0, response_format: { type: "json_object" }, From 1e288b5bb71cd20852f9d62ccdbb83f117e2ecbc Mon Sep 17 00:00:00 2001 From: Benjamin Fenton <270411513+fentonbenjamin@users.noreply.github.com> Date: Fri, 27 Mar 2026 15:46:40 -0400 Subject: [PATCH 03/15] Add engine selector with auto-fallback to local Users can pick gpt-4.1, local (no LLM), or auto (try LLM, fall back to local). Fallback reason shown in result badges. Enables side-by-side comparison of LLM vs heuristic shaping. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/api/shape/route.ts | 14 +++++++++++++- components/shape-form.tsx | 14 +++++++++++++- components/shape-result.tsx | 10 +++++++++- lib/types.ts | 1 + 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/app/api/shape/route.ts b/app/api/shape/route.ts index 25c392a..b326f7c 100644 --- a/app/api/shape/route.ts +++ b/app/api/shape/route.ts @@ -28,7 +28,19 @@ export async function POST(request: NextRequest) { ? (engine as ShapeEngine) : "openai"; - const result = await shape(text, profileOverride, engineOverride); + let result; + try { + result = await shape(text, profileOverride, engineOverride); + } catch (primaryErr) { + // If an LLM engine failed, fall back to local with a note + if (engineOverride !== "local") { + result = await shape(text, profileOverride, "local"); + result.engine = "local" as ShapeEngine; + result.fallback_reason = primaryErr instanceof Error ? primaryErr.message : "LLM unavailable"; + } else { + throw primaryErr; + } + } return NextResponse.json(result); } catch (err: unknown) { const message = err instanceof Error ? err.message : "Unknown error"; diff --git a/components/shape-form.tsx b/components/shape-form.tsx index eaef0b4..491c426 100644 --- a/components/shape-form.tsx +++ b/components/shape-form.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useRef } from "react"; -import type { ShapeProfile } from "@/lib/types"; +import type { ShapeProfile, ShapeEngine } from "@/lib/types"; export function ShapeForm({ onResult, @@ -14,6 +14,7 @@ export function ShapeForm({ }) { const [text, setText] = useState(""); const [profile, setProfile] = useState("auto"); + const [engine, setEngine] = useState("auto"); const [loading, setLoading] = useState(false); const [elapsed, setElapsed] = useState(0); const timerRef = useRef | null>(null); @@ -40,6 +41,7 @@ export function ShapeForm({ try { const body: Record = { text }; if (profile !== "auto") body.profile = profile; + if (engine !== "auto") body.engine = engine; const res = await fetch("/api/shape", { method: "POST", @@ -95,6 +97,16 @@ export function ShapeForm({ + {loading && ( {elapsed.toFixed(1)}s diff --git a/components/shape-result.tsx b/components/shape-result.tsx index 1d96020..caf5cc2 100644 --- a/components/shape-result.tsx +++ b/components/shape-result.tsx @@ -87,7 +87,7 @@ export function ShapeResult({ result }: { result: ShapeResult }) { const [visibleSpine, setVisibleSpine] = useState(0); const [titleVisible, setTitleVisible] = useState(false); const [badgesVisible, setBadgesVisible] = useState(false); - const { profile, spine, output, support, casts, check } = result; + const { profile, engine, spine, output, support, casts, check, fallback_reason } = result; // Staggered reveal: badges → title → spine lines one by one useEffect(() => { @@ -122,12 +122,20 @@ export function ShapeResult({ result }: { result: ShapeResult }) { }`}> {output.signal_level} + + {engine === "local" ? "local" : "gpt-4.1"} + {check.compression_holds && ( compressed )}
+ {fallback_reason && ( +

+ fell back to local engine — {fallback_reason} +

+ )}

{output.title} diff --git a/lib/types.ts b/lib/types.ts index 3606482..e0bcd96 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -34,4 +34,5 @@ export interface ShapeResult { host_json_view: object; }; check: ShapeCheck; + fallback_reason?: string; } From fda160e9e5db311ad234ac340f69973db1033d03 Mon Sep 17 00:00:00 2001 From: Benjamin Fenton <270411513+fentonbenjamin@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:05:20 -0400 Subject: [PATCH 04/15] Multi-model engine: GPT-4.1, Claude Sonnet 4.6, Gemini 2.5 Flash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three LLM options in dropdown. Any failure silently falls back to local heuristic engine with a note explaining why. No explicit local option in the picker — it's the safety net, not a choice. Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.local.example | 2 ++ app/api/shape/route.ts | 2 +- components/shape-form.tsx | 12 +++---- components/shape-result.tsx | 2 +- lib/engine.ts | 12 +++++-- lib/model.ts | 69 ++++++++++++++++++++++++++++++++----- lib/types.ts | 2 +- 7 files changed, 81 insertions(+), 20 deletions(-) diff --git a/.env.local.example b/.env.local.example index ed6ed73..1d77eb1 100644 --- a/.env.local.example +++ b/.env.local.example @@ -1 +1,3 @@ OPENAI_API_KEY=sk-... +ANTHROPIC_API_KEY=sk-ant-... +GEMINI_API_KEY=AI... diff --git a/app/api/shape/route.ts b/app/api/shape/route.ts index b326f7c..6962b69 100644 --- a/app/api/shape/route.ts +++ b/app/api/shape/route.ts @@ -3,7 +3,7 @@ import { shape } from "@/lib/shape"; import type { ShapeEngine, ShapeProfile } from "@/lib/types"; const VALID_PROFILES = ["narrative_segment_v0", "concept_blob_v0"]; -const VALID_ENGINES = ["openai", "local"]; +const VALID_ENGINES = ["openai", "anthropic", "gemini", "local"]; export async function POST(request: NextRequest) { try { diff --git a/components/shape-form.tsx b/components/shape-form.tsx index 491c426..6db93d9 100644 --- a/components/shape-form.tsx +++ b/components/shape-form.tsx @@ -14,7 +14,7 @@ export function ShapeForm({ }) { const [text, setText] = useState(""); const [profile, setProfile] = useState("auto"); - const [engine, setEngine] = useState("auto"); + const [engine, setEngine] = useState("openai"); const [loading, setLoading] = useState(false); const [elapsed, setElapsed] = useState(0); const timerRef = useRef | null>(null); @@ -41,7 +41,7 @@ export function ShapeForm({ try { const body: Record = { text }; if (profile !== "auto") body.profile = profile; - if (engine !== "auto") body.engine = engine; + body.engine = engine; const res = await fetch("/api/shape", { method: "POST", @@ -99,13 +99,13 @@ export function ShapeForm({ {loading && ( diff --git a/components/shape-result.tsx b/components/shape-result.tsx index caf5cc2..8f0867e 100644 --- a/components/shape-result.tsx +++ b/components/shape-result.tsx @@ -123,7 +123,7 @@ export function ShapeResult({ result }: { result: ShapeResult }) { {output.signal_level} - {engine === "local" ? "local" : "gpt-4.1"} + {engine === "local" ? "local" : engine === "anthropic" ? "claude" : engine === "gemini" ? "gemini" : "gpt-4.1"} {check.compression_holds && ( diff --git a/lib/engine.ts b/lib/engine.ts index b0f769e..4cf8c87 100644 --- a/lib/engine.ts +++ b/lib/engine.ts @@ -1,6 +1,6 @@ import { buildSystemPrompt } from "./prompt"; import { runLocalShape } from "./local-engine"; -import { runOpenAIShapePrompt } from "./model"; +import { runOpenAIShapePrompt, runAnthropicShapePrompt, runGeminiShapePrompt } from "./model"; import type { ShapeEngine, ShapeProfile } from "./types"; export async function runShapeEngine({ @@ -17,7 +17,15 @@ export async function runShapeEngine({ } const systemPrompt = buildSystemPrompt(profile); - const raw = await runOpenAIShapePrompt({ systemPrompt, userText }); + let raw: string; + + if (engine === "anthropic") { + raw = await runAnthropicShapePrompt({ systemPrompt, userText }); + } else if (engine === "gemini") { + raw = await runGeminiShapePrompt({ systemPrompt, userText }); + } else { + raw = await runOpenAIShapePrompt({ systemPrompt, userText }); + } try { return JSON.parse(raw); diff --git a/lib/model.ts b/lib/model.ts index 3ec93db..050bfa5 100644 --- a/lib/model.ts +++ b/lib/model.ts @@ -1,20 +1,36 @@ import OpenAI from "openai"; +import Anthropic from "@anthropic-ai/sdk"; +import { GoogleGenAI } from "@google/genai"; -let _client: OpenAI | null = null; -function getClient() { - if (!_client) _client = new OpenAI(); - return _client; +// Lazy-init all clients to avoid build-time crashes when keys aren't set + +let _openai: OpenAI | null = null; +function getOpenAI() { + if (!_openai) _openai = new OpenAI(); + return _openai; +} + +let _anthropic: Anthropic | null = null; +function getAnthropic() { + if (!_anthropic) _anthropic = new Anthropic(); + return _anthropic; } -export async function runShapePrompt({ +let _gemini: GoogleGenAI | null = null; +function getGemini() { + if (!_gemini) _gemini = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY ?? "" }); + return _gemini; +} + +export async function runOpenAIShapePrompt({ systemPrompt, userText, }: { systemPrompt: string; userText: string; }): Promise { - const response = await getClient().chat.completions.create({ - model: "gpt-4.1-mini", + const response = await getOpenAI().chat.completions.create({ + model: "gpt-4.1", temperature: 0, response_format: { type: "json_object" }, messages: [ @@ -22,8 +38,43 @@ export async function runShapePrompt({ { role: "user", content: userText }, ], }); - return response.choices[0]?.message?.content ?? ""; } -export { runShapePrompt as runOpenAIShapePrompt }; +export async function runAnthropicShapePrompt({ + systemPrompt, + userText, +}: { + systemPrompt: string; + userText: string; +}): Promise { + const response = await getAnthropic().messages.create({ + model: "claude-sonnet-4-6", + max_tokens: 4096, + temperature: 0, + system: systemPrompt, + messages: [{ role: "user", content: userText }], + }); + const block = response.content[0]; + if (block.type !== "text") throw new Error("Unexpected Anthropic response type"); + return block.text; +} + +export async function runGeminiShapePrompt({ + systemPrompt, + userText, +}: { + systemPrompt: string; + userText: string; +}): Promise { + const response = await getGemini().models.generateContent({ + model: "gemini-2.5-flash", + contents: userText, + config: { + systemInstruction: systemPrompt, + temperature: 0, + responseMimeType: "application/json", + }, + }); + return response.text ?? ""; +} diff --git a/lib/types.ts b/lib/types.ts index e0bcd96..b044970 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,7 +1,7 @@ import type { ShapeProfile, NarrativeSegment, ConceptBlob } from "./output-schema"; export type { ShapeProfile, NarrativeSegment, ConceptBlob }; -export type ShapeEngine = "openai" | "local"; +export type ShapeEngine = "openai" | "anthropic" | "gemini" | "local"; export interface SupportEntry { kind: "explicit" | "inferred"; From 9ce7a9337088d97ba1044cb884d87348d40f41ba Mon Sep 17 00:00:00 2001 From: Benjamin Fenton <270411513+fentonbenjamin@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:08:44 -0400 Subject: [PATCH 05/15] Add local engine option to model picker Co-Authored-By: Claude Opus 4.6 (1M context) --- components/shape-form.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/components/shape-form.tsx b/components/shape-form.tsx index 6db93d9..cf69a86 100644 --- a/components/shape-form.tsx +++ b/components/shape-form.tsx @@ -106,6 +106,7 @@ export function ShapeForm({ + {loading && ( From 87892732ad16992201872041002e554d94363a08 Mon Sep 17 00:00:00 2001 From: Benjamin Fenton <270411513+fentonbenjamin@users.noreply.github.com> Date: Fri, 27 Mar 2026 20:06:21 -0400 Subject: [PATCH 06/15] Add Supabase auth, shape persistence, and history page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Google OAuth + magic link sign-in (wow before login) - Save button on results — prompts sign-in if anonymous - Full canonical ShapeResult saved as JSONB - History page at /history with shape listing - RLS: users can only see their own shapes - Schema: shapes, collections, collection_items Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.local.example | 2 + .gitignore | 1 + CLAUDE.md | 24 ++ app/history/page.tsx | 112 ++++++ app/page.tsx | 75 +++-- components/auth-provider.tsx | 47 +++ components/shape-form.tsx | 4 +- components/shape-result.tsx | 41 ++- components/sign-in-modal.tsx | 83 +++++ components/user-menu.tsx | 46 +++ lib/save-shape.ts | 43 +++ lib/supabase.ts | 8 + next-env.d.ts | 2 +- package-lock.json | 637 +++++++++++++++++++++++++++++++++++ package.json | 4 + 15 files changed, 1090 insertions(+), 39 deletions(-) create mode 100644 CLAUDE.md create mode 100644 app/history/page.tsx create mode 100644 components/auth-provider.tsx create mode 100644 components/sign-in-modal.tsx create mode 100644 components/user-menu.tsx create mode 100644 lib/save-shape.ts create mode 100644 lib/supabase.ts diff --git a/.env.local.example b/.env.local.example index 1d77eb1..7d36b0a 100644 --- a/.env.local.example +++ b/.env.local.example @@ -1,3 +1,5 @@ OPENAI_API_KEY=sk-... ANTHROPIC_API_KEY=sk-ant-... GEMINI_API_KEY=AI... +NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ... diff --git a/.gitignore b/.gitignore index 8536099..2a126bb 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ node_modules/ .env out/ .shape-build/ +.vercel diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3f10b93 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,24 @@ + +## Best practices for developing on Vercel + +These defaults are optimized for AI coding agents (and humans) working on apps that deploy to Vercel. + +- Treat Vercel Functions as stateless + ephemeral (no durable RAM/FS, no background daemons), use Blob or marketplace integrations for preserving state +- Edge Functions (standalone) are deprecated; prefer Vercel Functions +- Don't start new projects on Vercel KV/Postgres (both discontinued); use Marketplace Redis/Postgres instead +- Store secrets in Vercel Env Variables; not in git or `NEXT_PUBLIC_*` +- Provision Marketplace native integrations with `vercel integration add` (CI/agent-friendly) +- Sync env + project settings with `vercel env pull` / `vercel pull` when you need local/offline parity +- Use `waitUntil` for post-response work; avoid the deprecated Function `context` parameter +- Set Function regions near your primary data source; avoid cross-region DB/service roundtrips +- Tune Fluid Compute knobs (e.g., `maxDuration`, memory/CPU) for long I/O-heavy calls (LLMs, APIs) +- Use Runtime Cache for fast **regional** caching + tag invalidation (don't treat it as global KV) +- Use Cron Jobs for schedules; cron runs in UTC and triggers your production URL via HTTP GET +- Use Vercel Blob for uploads/media; Use Edge Config for small, globally-read config +- If Enable Deployment Protection is enabled, use a bypass secret to directly access them +- Add OpenTelemetry via `@vercel/otel` on Node; don't expect OTEL support on the Edge runtime +- Enable Web Analytics + Speed Insights early +- Use AI Gateway for model routing, set AI_GATEWAY_API_KEY, using a model string (e.g. 'anthropic/claude-sonnet-4.6'), Gateway is already default in AI SDK + needed. Always curl https://ai-gateway.vercel.sh/v1/models first; never trust model IDs from memory +- For durable agent loops or untrusted code: use Workflow (pause/resume/state) + Sandbox; use Vercel MCP for secure infra access + diff --git a/app/history/page.tsx b/app/history/page.tsx new file mode 100644 index 0000000..8c30253 --- /dev/null +++ b/app/history/page.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { AuthProvider } from "@/components/auth-provider"; +import { useAuth } from "@/components/auth-provider"; +import { listShapes } from "@/lib/save-shape"; + +interface ShapeRow { + id: string; + title: string; + profile: string; + engine: string; + signal_level: string; + created_at: string; +} + +function HistoryList() { + const { user, loading: authLoading } = useAuth(); + const [shapes, setShapes] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (authLoading) return; + if (!user) { + setLoading(false); + return; + } + listShapes() + .then((data) => setShapes(data as ShapeRow[])) + .finally(() => setLoading(false)); + }, [user, authLoading]); + + if (authLoading || loading) { + return

Loading…

; + } + + if (!user) { + return ( +

+ + Sign in + {" "} + to see your saved shapes. +

+ ); + } + + if (shapes.length === 0) { + return ( +

+ No shapes yet.{" "} + + Shape something + . +

+ ); + } + + return ( +
+ {shapes.map((s) => ( +
+
+

{s.title}

+
+ + {s.profile === "narrative_segment_v0" ? "narrative" : "concept"} + + {s.engine} + + {s.signal_level} + +
+
+

+ {new Date(s.created_at).toLocaleDateString("en-US", { + month: "short", day: "numeric", year: "numeric", + hour: "numeric", minute: "2-digit", + })} +

+
+ ))} +
+ ); +} + +export default function HistoryPage() { + return ( + +
+
+
+

History

+ + back to shape + +
+ +
+
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index 585145b..e628180 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -3,48 +3,55 @@ import { useState } from "react"; import { ShapeForm } from "@/components/shape-form"; import { ShapeResult as ShapeResultView } from "@/components/shape-result"; +import { AuthProvider } from "@/components/auth-provider"; +import { UserMenu } from "@/components/user-menu"; import type { ShapeResult } from "@/lib/types"; export default function Home() { const [result, setResult] = useState(null); + const [sourceText, setSourceText] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); return ( -
- {/* Top progress bar */} - {loading && ( -
-
+ +
+ {loading && ( +
+
+
+ )} + + + +
+

+ Shape +

+

+ Paste text. Get structure. See what survived. +

- )} - -
-

- Shape -

-

- Paste text. Get structure. See what survived. -

-
- - { - setResult(data as ShapeResult); - setError(""); - }} - onError={(msg) => { - setError(msg); - setResult(null); - }} - onLoading={setLoading} - /> - - {error && ( -

{error}

- )} - - {result && } -
+ + { + setResult(data as ShapeResult); + setSourceText(text); + setError(""); + }} + onError={(msg) => { + setError(msg); + setResult(null); + }} + onLoading={setLoading} + /> + + {error && ( +

{error}

+ )} + + {result && } +
+ ); } diff --git a/components/auth-provider.tsx b/components/auth-provider.tsx new file mode 100644 index 0000000..507571a --- /dev/null +++ b/components/auth-provider.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { createContext, useContext, useEffect, useState } from "react"; +import type { User } from "@supabase/supabase-js"; +import { getSupabase } from "@/lib/supabase"; + +const AuthContext = createContext<{ + user: User | null; + loading: boolean; + signOut: () => Promise; +}>({ user: null, loading: true, signOut: async () => {} }); + +export function useAuth() { + return useContext(AuthContext); +} + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const supabase = getSupabase(); + + supabase.auth.getUser().then(({ data }) => { + setUser(data.user); + setLoading(false); + }); + + const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => { + setUser(session?.user ?? null); + }); + + return () => subscription.unsubscribe(); + }, []); + + async function signOut() { + const supabase = getSupabase(); + await supabase.auth.signOut(); + setUser(null); + } + + return ( + + {children} + + ); +} diff --git a/components/shape-form.tsx b/components/shape-form.tsx index cf69a86..636ac12 100644 --- a/components/shape-form.tsx +++ b/components/shape-form.tsx @@ -8,7 +8,7 @@ export function ShapeForm({ onError, onLoading, }: { - onResult: (data: unknown) => void; + onResult: (data: unknown, sourceText: string) => void; onError: (msg: string) => void; onLoading: (loading: boolean) => void; }) { @@ -54,7 +54,7 @@ export function ShapeForm({ if (!res.ok) { onError(data.error || "Something went wrong"); } else { - onResult(data); + onResult(data, text); } } catch { onError("Failed to reach the API"); diff --git a/components/shape-result.tsx b/components/shape-result.tsx index 8f0867e..66ff243 100644 --- a/components/shape-result.tsx +++ b/components/shape-result.tsx @@ -4,6 +4,9 @@ import { useState, useEffect } from "react"; import type { ShapeResult, NarrativeSegment, ConceptBlob, SupportMap } from "@/lib/types"; import { CastView } from "./cast-view"; import { CheckView } from "./check-view"; +import { useAuth } from "./auth-provider"; +import { SignInModal } from "./sign-in-modal"; +import { saveShape } from "@/lib/save-shape"; function downloadFile(content: string, filename: string, type: string) { const blob = new Blob([content], { type }); @@ -81,14 +84,36 @@ function ConceptView({ output }: { output: ConceptBlob }) { ); } -export function ShapeResult({ result }: { result: ShapeResult }) { +export function ShapeResult({ result, sourceText }: { result: ShapeResult; sourceText: string }) { const [tab, setTab] = useState<"readable" | "json" | "card">("readable"); const [showFull, setShowFull] = useState(false); const [visibleSpine, setVisibleSpine] = useState(0); const [titleVisible, setTitleVisible] = useState(false); const [badgesVisible, setBadgesVisible] = useState(false); + const [saved, setSaved] = useState(false); + const [saving, setSaving] = useState(false); + const [showSignIn, setShowSignIn] = useState(false); + const { user } = useAuth(); const { profile, engine, spine, output, support, casts, check, fallback_reason } = result; + useEffect(() => { setSaved(false); }, [result]); + + async function handleSave() { + if (!user) { + setShowSignIn(true); + return; + } + setSaving(true); + try { + await saveShape(sourceText, result); + setSaved(true); + } catch { + // silently fail — user can retry + } finally { + setSaving(false); + } + } + // Staggered reveal: badges → title → spine lines one by one useEffect(() => { setVisibleSpine(0); @@ -247,7 +272,7 @@ export function ShapeResult({ result }: { result: ShapeResult }) { onClick={() => copyToClipboard(spine.join("\n"))} className="text-xs font-mono px-3 py-1.5 rounded border border-neutral-800 text-neutral-500 hover:text-neutral-300 hover:border-neutral-600 transition-colors" > - Copy spine + Copy summary +

+ {showSignIn && setShowSignIn(false)} />}
); } diff --git a/components/sign-in-modal.tsx b/components/sign-in-modal.tsx new file mode 100644 index 0000000..b8722cb --- /dev/null +++ b/components/sign-in-modal.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useState } from "react"; +import { getSupabase } from "@/lib/supabase"; + +export function SignInModal({ onClose }: { onClose: () => void }) { + const [email, setEmail] = useState(""); + const [sent, setSent] = useState(false); + const [error, setError] = useState(""); + + async function handleGoogle() { + const supabase = getSupabase(); + await supabase.auth.signInWithOAuth({ + provider: "google", + options: { redirectTo: window.location.origin }, + }); + } + + async function handleMagicLink() { + if (!email.trim()) return; + setError(""); + const supabase = getSupabase(); + const { error } = await supabase.auth.signInWithOtp({ + email, + options: { emailRedirectTo: window.location.origin }, + }); + if (error) { + setError(error.message); + } else { + setSent(true); + } + } + + return ( +
+
e.stopPropagation()}> +

Save your shape

+

Sign in to save and revisit your meaning objects.

+ + + +
+
+ or +
+
+ + {sent ? ( +

Check your email for the sign-in link.

+ ) : ( +
+ setEmail(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleMagicLink()} + placeholder="you@example.com" + className="w-full bg-neutral-950 border border-neutral-800 rounded-lg px-3 py-2 text-sm text-neutral-200 placeholder:text-neutral-600 focus:outline-none focus:border-neutral-600" + /> + +
+ )} + + {error &&

{error}

} + + +
+
+ ); +} diff --git a/components/user-menu.tsx b/components/user-menu.tsx new file mode 100644 index 0000000..f18a60c --- /dev/null +++ b/components/user-menu.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useState } from "react"; +import { useAuth } from "./auth-provider"; +import { SignInModal } from "./sign-in-modal"; + +export function UserMenu() { + const { user, loading, signOut } = useAuth(); + const [showSignIn, setShowSignIn] = useState(false); + + if (loading) return null; + + return ( + <> +
+ {user ? ( +
+ + {user.email} + + + history + + +
+ ) : ( + + )} +
+ {showSignIn && setShowSignIn(false)} />} + + ); +} diff --git a/lib/save-shape.ts b/lib/save-shape.ts new file mode 100644 index 0000000..9ac1e82 --- /dev/null +++ b/lib/save-shape.ts @@ -0,0 +1,43 @@ +import { getSupabase } from "./supabase"; +import type { ShapeResult } from "./types"; + +export async function saveShape(sourceText: string, result: ShapeResult) { + const supabase = getSupabase(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) throw new Error("Not signed in"); + + const { error } = await supabase.from("shapes").insert({ + user_id: user.id, + source_text: sourceText, + profile: result.profile, + engine: result.engine, + result: result, + title: result.output.title, + signal_level: result.output.signal_level, + }); + + if (error) throw new Error(error.message); +} + +export async function listShapes() { + const supabase = getSupabase(); + const { data, error } = await supabase + .from("shapes") + .select("id, title, profile, engine, signal_level, created_at") + .order("created_at", { ascending: false }); + + if (error) throw new Error(error.message); + return data; +} + +export async function getShape(id: string) { + const supabase = getSupabase(); + const { data, error } = await supabase + .from("shapes") + .select("*") + .eq("id", id) + .single(); + + if (error) throw new Error(error.message); + return data; +} diff --git a/lib/supabase.ts b/lib/supabase.ts new file mode 100644 index 0000000..74687dc --- /dev/null +++ b/lib/supabase.ts @@ -0,0 +1,8 @@ +import { createBrowserClient } from "@supabase/ssr"; + +export function getSupabase() { + return createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ); +} diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818..9edff1c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package-lock.json b/package-lock.json index 2894661..b01193d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,10 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@anthropic-ai/sdk": "^0.80.0", + "@google/genai": "^1.46.0", + "@supabase/ssr": "^0.9.0", + "@supabase/supabase-js": "^2.100.1", "@tailwindcss/postcss": "^4.2.2", "@types/node": "^25.5.0", "@types/react": "^19.2.14", @@ -35,6 +39,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.80.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.80.0.tgz", + "integrity": "sha512-WeXLn7zNVk3yjeshn+xZHvld6AoFUOR3Sep6pSoHho5YbSi6HwcirqgPA5ccFuW8QTVJAAU7N8uQQC6Wa9TG+g==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@emnapi/runtime": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", @@ -45,6 +78,29 @@ "tslib": "^2.4.0" } }, + "node_modules/@google/genai": { + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.46.0.tgz", + "integrity": "sha512-ewPMN5JkKfgU5/kdco9ZhXBHDPhVqZpMQqIFQhwsHLf8kyZfx1cNpw1pHo1eV6PGEW7EhIBFi3aYZraFndAXqg==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -690,6 +746,168 @@ "node": ">= 10" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, + "node_modules/@supabase/auth-js": { + "version": "2.100.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.100.1.tgz", + "integrity": "sha512-c5FB4nrG7cs1mLSzFGuIVl2iR2YO5XkSJ96uF4zubYm8YDn71XOi2emE9sBm/avfGCj61jaRBLOvxEAVnpys0Q==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.100.1", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.100.1.tgz", + "integrity": "sha512-mo8QheoV4KR+wSubtyEWhZUxWnCM7YZ23TncccMAlbWAHb8YTDqRGRm9IalWCAswniKyud6buZCk9snRqI86KA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.0.tgz", + "integrity": "sha512-RHSx8bHS02xwfHdAbX5Lpbo6PXbgyf7lTaXTlwtFDPwOIw64NnVRwFAXGojHhjtVYI+PEPNSWwkL90f4agN3bw==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.100.1", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.100.1.tgz", + "integrity": "sha512-OIh4mOSo2LdqF2kox76OAPDtcSs+PwKABJOjc6plUV4/LXhFEsI2uwdEEIs7K7fd141qehWEVl/Y+Ts0fNvYsw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.100.1", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.100.1.tgz", + "integrity": "sha512-FHuRWPX4qZQ4x+0Q+ZrKaBZnOiVGiwsgiAUJM98pYRib1yeaE/fOM1lZ1ozd+4gA8Udw23OyaD8SxKS5mT5NYw==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "^0.4.0", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/ssr": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.9.0.tgz", + "integrity": "sha512-UFY6otYV3yqCgV+AyHj80vNkTvbf1Gas2LW4dpbQ4ap6p6v3eB2oaDfcI99jsuJzwVBCFU4BJI+oDYyhNk1z0Q==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.2" + }, + "peerDependencies": { + "@supabase/supabase-js": "^2.97.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.100.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.100.1.tgz", + "integrity": "sha512-x9xpEIoWM4xKiAlwfWTgHPSN6N4Y0aS4FVU4F6ZPbq7Gayw08SrtC6/YH/gOr8CjXQr0HxXYXDop2xGTSjubYA==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.100.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.100.1.tgz", + "integrity": "sha512-CAeFm5sfX8sbTzxoxRafhohreIzl9a7R6qHTck3MrgTqm5M5g/u0IHfEKYzI9w/17r8NINl8UZrw2i08wrO7Iw==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.100.1", + "@supabase/functions-js": "2.100.1", + "@supabase/postgrest-js": "2.100.1", + "@supabase/realtime-js": "2.100.1", + "@supabase/storage-js": "2.100.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -982,6 +1200,50 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.11", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.11.tgz", @@ -994,6 +1256,21 @@ "node": ">=6.0.0" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/caniuse-lite": { "version": "1.0.30001781", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", @@ -1020,12 +1297,51 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1035,6 +1351,15 @@ "node": ">=8" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/enhanced-resolve": { "version": "5.20.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", @@ -1048,12 +1373,129 @@ "node": ">=10.13.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -1063,6 +1505,49 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -1312,6 +1797,12 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1321,6 +1812,12 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1420,6 +1917,44 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/openai": { "version": "6.33.0", "resolved": "https://registry.npmjs.org/openai/-/openai-6.33.0.tgz", @@ -1441,6 +1976,19 @@ } } }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1475,6 +2023,30 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -1496,6 +2068,35 @@ "react": "^19.2.4" } }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -1611,6 +2212,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -1636,6 +2243,36 @@ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "license": "MIT" }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", diff --git a/package.json b/package.json index ae7c45d..79cff72 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,10 @@ "author": "", "license": "ISC", "dependencies": { + "@anthropic-ai/sdk": "^0.80.0", + "@google/genai": "^1.46.0", + "@supabase/ssr": "^0.9.0", + "@supabase/supabase-js": "^2.100.1", "@tailwindcss/postcss": "^4.2.2", "@types/node": "^25.5.0", "@types/react": "^19.2.14", From b623e620de3878209f9aecd8fbfc8427aca5f99d Mon Sep 17 00:00:00 2001 From: Benjamin Fenton <270411513+fentonbenjamin@users.noreply.github.com> Date: Fri, 27 Mar 2026 21:20:26 -0400 Subject: [PATCH 07/15] Fix auth hydration, update tagline - Use getSession() instead of getUser() to avoid hanging - Add catch/finally so loading always resolves - Client-only mount for UserMenu to prevent hydration mismatch - Tagline: "Paste text, get structured meaning." Co-Authored-By: Claude Opus 4.6 (1M context) --- app/page.tsx | 2 +- components/auth-provider.tsx | 12 ++++++++---- components/user-menu.tsx | 9 ++++++--- lib/supabase.ts | 13 +++++++++---- 4 files changed, 24 insertions(+), 12 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index e628180..ee0e9f2 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -29,7 +29,7 @@ export default function Home() { Shape

- Paste text. Get structure. See what survived. + Paste text, get structured meaning.

diff --git a/components/auth-provider.tsx b/components/auth-provider.tsx index 507571a..6742234 100644 --- a/components/auth-provider.tsx +++ b/components/auth-provider.tsx @@ -1,7 +1,7 @@ "use client"; import { createContext, useContext, useEffect, useState } from "react"; -import type { User } from "@supabase/supabase-js"; +import type { User, AuthChangeEvent, Session } from "@supabase/supabase-js"; import { getSupabase } from "@/lib/supabase"; const AuthContext = createContext<{ @@ -21,13 +21,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { useEffect(() => { const supabase = getSupabase(); - supabase.auth.getUser().then(({ data }) => { - setUser(data.user); + supabase.auth.getSession().then(({ data }: { data: { session: { user: User } | null } }) => { + setUser(data.session?.user ?? null); + }).catch(() => { + // auth unavailable — continue as anonymous + }).finally(() => { setLoading(false); }); - const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => { + const { data: { subscription } } = supabase.auth.onAuthStateChange((_event: AuthChangeEvent, session: Session | null) => { setUser(session?.user ?? null); + setLoading(false); }); return () => subscription.unsubscribe(); diff --git a/components/user-menu.tsx b/components/user-menu.tsx index f18a60c..119d696 100644 --- a/components/user-menu.tsx +++ b/components/user-menu.tsx @@ -1,14 +1,17 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useAuth } from "./auth-provider"; import { SignInModal } from "./sign-in-modal"; export function UserMenu() { - const { user, loading, signOut } = useAuth(); + const { user, signOut } = useAuth(); const [showSignIn, setShowSignIn] = useState(false); + const [mounted, setMounted] = useState(false); - if (loading) return null; + useEffect(() => setMounted(true), []); + + if (!mounted) return null; return ( <> diff --git a/lib/supabase.ts b/lib/supabase.ts index 74687dc..28390b1 100644 --- a/lib/supabase.ts +++ b/lib/supabase.ts @@ -1,8 +1,13 @@ import { createBrowserClient } from "@supabase/ssr"; +let _supabase: ReturnType | null = null; + export function getSupabase() { - return createBrowserClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! - ); + if (!_supabase) { + _supabase = createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ); + } + return _supabase; } From 6c7910c30fcc8cefac73ef911b8897b717c9e5cc Mon Sep 17 00:00:00 2001 From: Benjamin Fenton <270411513+fentonbenjamin@users.noreply.github.com> Date: Fri, 27 Mar 2026 21:25:21 -0400 Subject: [PATCH 08/15] Support GOOGLE_API_KEY as fallback for Gemini Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/model.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/model.ts b/lib/model.ts index 050bfa5..67f9db6 100644 --- a/lib/model.ts +++ b/lib/model.ts @@ -18,7 +18,7 @@ function getAnthropic() { let _gemini: GoogleGenAI | null = null; function getGemini() { - if (!_gemini) _gemini = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY ?? "" }); + if (!_gemini) _gemini = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY ?? "" }); return _gemini; } From 9edbc19a7d0afb20c519e8f20066f63cbfc814dc Mon Sep 17 00:00:00 2001 From: Benjamin Fenton <270411513+fentonbenjamin@users.noreply.github.com> Date: Fri, 27 Mar 2026 21:33:10 -0400 Subject: [PATCH 09/15] Normalize support evidence arrays before validation Gemini returns evidence as string instead of string[]. Coerce before Zod parse so all three models pass validation. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/output-schema.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/lib/output-schema.ts b/lib/output-schema.ts index 757c976..b03f80c 100644 --- a/lib/output-schema.ts +++ b/lib/output-schema.ts @@ -53,8 +53,29 @@ export type ConceptBlob = z.infer; export type ShapeProfile = "narrative_segment_v0" | "concept_blob_v0"; export type ShapeOutput = NarrativeSegment | ConceptBlob; +function normalizeSupportMap(data: Record): Record { + if (!data || typeof data !== "object") return data; + const support = (data as Record).support; + if (support && typeof support === "object") { + for (const [field, entries] of Object.entries(support as Record)) { + if (Array.isArray(entries)) { + for (const entry of entries) { + if (entry && typeof entry === "object" && "evidence" in entry) { + const e = entry as Record; + if (typeof e.evidence === "string") { + e.evidence = [e.evidence]; + } + } + } + } + } + } + return data; +} + export function validateModelResponse(profile: ShapeProfile, data: unknown) { - const parsed = shapeModelResponseSchema.parse(data); + const normalized = normalizeSupportMap(data as Record); + const parsed = shapeModelResponseSchema.parse(normalized); if (profile === "narrative_segment_v0") { narrativeSegmentSchema.parse(parsed.result); } else { From 308cc0dfce422ea7e251de5599cc426d777bd3ac Mon Sep 17 00:00:00 2001 From: Benjamin Fenton <270411513+fentonbenjamin@users.noreply.github.com> Date: Fri, 27 Mar 2026 22:12:31 -0400 Subject: [PATCH 10/15] Fix blank page: dynamic import Supabase to prevent hydration crash Lazy-load Supabase client in useEffect to avoid SSR/hydration failures that caused blank white screen in production. Co-Authored-By: Claude Opus 4.6 (1M context) --- components/auth-provider.tsx | 57 ++++++++++++++++++++++-------------- lib/supabase.ts | 10 ++++--- 2 files changed, 41 insertions(+), 26 deletions(-) diff --git a/components/auth-provider.tsx b/components/auth-provider.tsx index 6742234..4d150fb 100644 --- a/components/auth-provider.tsx +++ b/components/auth-provider.tsx @@ -1,14 +1,13 @@ "use client"; import { createContext, useContext, useEffect, useState } from "react"; -import type { User, AuthChangeEvent, Session } from "@supabase/supabase-js"; -import { getSupabase } from "@/lib/supabase"; +import type { User } from "@supabase/supabase-js"; const AuthContext = createContext<{ user: User | null; loading: boolean; signOut: () => Promise; -}>({ user: null, loading: true, signOut: async () => {} }); +}>({ user: null, loading: false, signOut: async () => {} }); export function useAuth() { return useContext(AuthContext); @@ -19,28 +18,42 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const [loading, setLoading] = useState(true); useEffect(() => { - const supabase = getSupabase(); - - supabase.auth.getSession().then(({ data }: { data: { session: { user: User } | null } }) => { - setUser(data.session?.user ?? null); - }).catch(() => { - // auth unavailable — continue as anonymous - }).finally(() => { - setLoading(false); - }); - - const { data: { subscription } } = supabase.auth.onAuthStateChange((_event: AuthChangeEvent, session: Session | null) => { - setUser(session?.user ?? null); - setLoading(false); - }); - - return () => subscription.unsubscribe(); + let cancelled = false; + + async function init() { + try { + const { getSupabase } = await import("@/lib/supabase"); + const supabase = getSupabase(); + + const { data } = await supabase.auth.getSession(); + if (!cancelled) { + setUser(data.session?.user ?? null); + setLoading(false); + } + + supabase.auth.onAuthStateChange((_event: string, session: { user: User } | null) => { + if (!cancelled) { + setUser(session?.user ?? null); + setLoading(false); + } + }); + } catch { + if (!cancelled) setLoading(false); + } + } + + init(); + return () => { cancelled = true; }; }, []); async function signOut() { - const supabase = getSupabase(); - await supabase.auth.signOut(); - setUser(null); + try { + const { getSupabase } = await import("@/lib/supabase"); + await getSupabase().auth.signOut(); + setUser(null); + } catch { + // ignore + } } return ( diff --git a/lib/supabase.ts b/lib/supabase.ts index 28390b1..68f1196 100644 --- a/lib/supabase.ts +++ b/lib/supabase.ts @@ -4,10 +4,12 @@ let _supabase: ReturnType | null = null; export function getSupabase() { if (!_supabase) { - _supabase = createBrowserClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! - ); + const url = process.env.NEXT_PUBLIC_SUPABASE_URL; + const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + if (!url || !key) { + throw new Error("Supabase env vars not set"); + } + _supabase = createBrowserClient(url, key); } return _supabase; } From 219c30e093969f15a9f251ecce3ddd80060db205 Mon Sep 17 00:00:00 2001 From: Benjamin Fenton <270411513+fentonbenjamin@users.noreply.github.com> Date: Fri, 27 Mar 2026 22:47:55 -0400 Subject: [PATCH 11/15] Complete capture-shape-save-revisit loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Permalink pages at /s/[id] — shareable links for saved shapes - RLS: shapes publicly readable, owner-writable - Save returns permalink, "Copy link" button appears after save - Auto-save after sign-in if save was pending - Rich history page with spine preview and badges - Mobile-responsive: wrapping controls, smaller textarea, fluid layout - Supabase auth redirects fixed for production URL - listShapes now returns result JSONB for spine display Co-Authored-By: Claude Opus 4.6 (1M context) --- app/history/page.tsx | 85 +++++++++++++++++++++++-------------- app/layout.tsx | 3 +- app/page.tsx | 8 ++-- app/s/[id]/page.tsx | 52 +++++++++++++++++++++++ components/shape-form.tsx | 6 +-- components/shape-result.tsx | 39 ++++++++++++++--- lib/save-shape.ts | 9 ++-- 7 files changed, 151 insertions(+), 51 deletions(-) create mode 100644 app/s/[id]/page.tsx diff --git a/app/history/page.tsx b/app/history/page.tsx index 8c30253..1c46769 100644 --- a/app/history/page.tsx +++ b/app/history/page.tsx @@ -1,9 +1,10 @@ "use client"; import { useEffect, useState } from "react"; -import { AuthProvider } from "@/components/auth-provider"; -import { useAuth } from "@/components/auth-provider"; +import { AuthProvider, useAuth } from "@/components/auth-provider"; +import { UserMenu } from "@/components/user-menu"; import { listShapes } from "@/lib/save-shape"; +import type { ShapeResult } from "@/lib/types"; interface ShapeRow { id: string; @@ -12,6 +13,7 @@ interface ShapeRow { engine: string; signal_level: string; created_at: string; + result: ShapeResult; } function HistoryList() { @@ -37,7 +39,7 @@ function HistoryList() { if (!user) { return (

- + Sign in {" "} to see your saved shapes. @@ -49,7 +51,7 @@ function HistoryList() { return (

No shapes yet.{" "} - + Shape something .

@@ -57,35 +59,53 @@ function HistoryList() { } return ( -
- {shapes.map((s) => ( -
-
-

{s.title}

- - ))} + {spine.length > 0 && ( +
+ {spine.slice(0, 3).map((line, i) => ( +

+ {line} +

+ ))} + {spine.length > 3 && ( +

+{spine.length - 3} more

+ )} +
+ )} +

+ {new Date(s.created_at).toLocaleDateString("en-US", { + month: "short", day: "numeric", year: "numeric", + hour: "numeric", minute: "2-digit", + })} +

+
+ ); + })}
); } @@ -94,6 +114,7 @@ export default function HistoryPage() { return (
+

History

diff --git a/app/layout.tsx b/app/layout.tsx index acf79d7..8392412 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,7 +3,8 @@ import "./globals.css"; export const metadata: Metadata = { title: "Shape", - description: "Paste text. Get structure. See what survived.", + description: "Paste text, get structured meaning.", + viewport: "width=device-width, initial-scale=1, maximum-scale=1", }; export default function RootLayout({ diff --git a/app/page.tsx b/app/page.tsx index ee0e9f2..b91e3f9 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -15,7 +15,7 @@ export default function Home() { return ( -
+
{loading && (
@@ -24,11 +24,11 @@ export default function Home() { -
-

+
+

Shape

-

+

Paste text, get structured meaning.

diff --git a/app/s/[id]/page.tsx b/app/s/[id]/page.tsx new file mode 100644 index 0000000..ea478af --- /dev/null +++ b/app/s/[id]/page.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import { ShapeResult as ShapeResultView } from "@/components/shape-result"; +import { AuthProvider } from "@/components/auth-provider"; +import { UserMenu } from "@/components/user-menu"; +import type { ShapeResult } from "@/lib/types"; +import { getSupabase } from "@/lib/supabase"; + +export default function ShapePermalink() { + const { id } = useParams<{ id: string }>(); + const [result, setResult] = useState(null); + const [sourceText, setSourceText] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!id) return; + const supabase = getSupabase(); + supabase + .from("shapes") + .select("result, source_text") + .eq("id", id) + .single() + .then(({ data, error: err }: { data: { result: ShapeResult; source_text: string } | null; error: unknown }) => { + if (err || !data) { + setError("Shape not found"); + } else { + setResult(data.result as ShapeResult); + setSourceText(data.source_text); + } + setLoading(false); + }); + }, [id]); + + return ( + +
+ + + {loading &&

Loading…

} + {error &&

{error}

} + {result && } +
+
+ ); +} diff --git a/components/shape-form.tsx b/components/shape-form.tsx index 636ac12..2a8eb88 100644 --- a/components/shape-form.tsx +++ b/components/shape-form.tsx @@ -78,12 +78,12 @@ export function ShapeForm({ onChange={(e) => setText(e.target.value)} onKeyDown={handleKeyDown} placeholder="Paste messy text here..." - rows={10} + rows={6} disabled={loading} className="w-full bg-neutral-900 border border-neutral-800 rounded-lg p-4 text-sm font-mono text-neutral-200 placeholder:text-neutral-600 focus:outline-none focus:border-neutral-600 resize-y disabled:opacity-50 transition-opacity" /> -
-
+
+
{text.length.toLocaleString()} chars diff --git a/components/shape-result.tsx b/components/shape-result.tsx index 66ff243..1a629e9 100644 --- a/components/shape-result.tsx +++ b/components/shape-result.tsx @@ -92,21 +92,29 @@ export function ShapeResult({ result, sourceText }: { result: ShapeResult; sourc const [badgesVisible, setBadgesVisible] = useState(false); const [saved, setSaved] = useState(false); const [saving, setSaving] = useState(false); + const [permalink, setPermalink] = useState(null); const [showSignIn, setShowSignIn] = useState(false); + const [pendingSave, setPendingSave] = useState(false); const { user } = useAuth(); const { profile, engine, spine, output, support, casts, check, fallback_reason } = result; - useEffect(() => { setSaved(false); }, [result]); + useEffect(() => { setSaved(false); setPermalink(null); }, [result]); - async function handleSave() { - if (!user) { - setShowSignIn(true); - return; + // Auto-save after sign-in if save was pending + useEffect(() => { + if (user && pendingSave && !saved && !saving) { + setPendingSave(false); + doSave(); } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user, pendingSave]); + + async function doSave() { setSaving(true); try { - await saveShape(sourceText, result); + const id = await saveShape(sourceText, result); setSaved(true); + setPermalink(`${window.location.origin}/s/${id}`); } catch { // silently fail — user can retry } finally { @@ -114,6 +122,15 @@ export function ShapeResult({ result, sourceText }: { result: ShapeResult; sourc } } + async function handleSave() { + if (!user) { + setPendingSave(true); + setShowSignIn(true); + return; + } + await doSave(); + } + // Staggered reveal: badges → title → spine lines one by one useEffect(() => { setVisibleSpine(0); @@ -136,7 +153,7 @@ export function ShapeResult({ result, sourceText }: { result: ShapeResult; sourc return (
-
+
{profile === "narrative_segment_v0" ? "narrative" : "concept"} @@ -309,6 +326,14 @@ export function ShapeResult({ result, sourceText }: { result: ShapeResult; sourc > {saved ? "Saved" : saving ? "Saving…" : "Save"} + {permalink && ( + + )}
{showSignIn && setShowSignIn(false)} />}
diff --git a/lib/save-shape.ts b/lib/save-shape.ts index 9ac1e82..d39ace1 100644 --- a/lib/save-shape.ts +++ b/lib/save-shape.ts @@ -1,12 +1,12 @@ import { getSupabase } from "./supabase"; import type { ShapeResult } from "./types"; -export async function saveShape(sourceText: string, result: ShapeResult) { +export async function saveShape(sourceText: string, result: ShapeResult): Promise { const supabase = getSupabase(); const { data: { user } } = await supabase.auth.getUser(); if (!user) throw new Error("Not signed in"); - const { error } = await supabase.from("shapes").insert({ + const { data, error } = await supabase.from("shapes").insert({ user_id: user.id, source_text: sourceText, profile: result.profile, @@ -14,16 +14,17 @@ export async function saveShape(sourceText: string, result: ShapeResult) { result: result, title: result.output.title, signal_level: result.output.signal_level, - }); + }).select("id").single(); if (error) throw new Error(error.message); + return data.id; } export async function listShapes() { const supabase = getSupabase(); const { data, error } = await supabase .from("shapes") - .select("id, title, profile, engine, signal_level, created_at") + .select("id, title, profile, engine, signal_level, created_at, result") .order("created_at", { ascending: false }); if (error) throw new Error(error.message); From 5a3b4e02213d4ae97312c3c0c0d50f8f46af435f Mon Sep 17 00:00:00 2001 From: Benjamin Fenton <270411513+fentonbenjamin@users.noreply.github.com> Date: Fri, 27 Mar 2026 22:53:27 -0400 Subject: [PATCH 12/15] Add History button to main Shape page header Co-Authored-By: Claude Opus 4.6 (1M context) --- app/page.tsx | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index b91e3f9..1f0a83f 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -24,13 +24,23 @@ export default function Home() { -
-

- Shape -

-

- Paste text, get structured meaning. -

+
+
+
+

+ Shape +

+

+ Paste text, get structured meaning. +

+
+ + History + +
Date: Sat, 28 Mar 2026 10:57:21 -0400 Subject: [PATCH 13/15] Fix duplicate history link and empty spine crash - Remove history from UserMenu (header button is enough) - Allow empty spine from model, fallback to title - Prevents "Too small: expected array >=1 items" validation error Co-Authored-By: Claude Opus 4.6 (1M context) --- components/user-menu.tsx | 6 ------ lib/output-schema.ts | 2 +- lib/shape.ts | 4 +++- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/components/user-menu.tsx b/components/user-menu.tsx index 119d696..627ea2d 100644 --- a/components/user-menu.tsx +++ b/components/user-menu.tsx @@ -21,12 +21,6 @@ export function UserMenu() { {user.email} - - history -