From c63db363901d86425d725aa539ff41f2d66ea661 Mon Sep 17 00:00:00 2001 From: Steve Brand Date: Fri, 28 Aug 2026 18:37:44 +0200 Subject: [PATCH 01/17] chore(deps): add use-stick-to-bottom --- CLAUDE.md | 2 ++ bun.lock | 75 ++++++++++++++++++++++++++-------------------------- package.json | 1 + 3 files changed, 41 insertions(+), 37 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6a5135d..02b0eb4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,9 +59,11 @@ nextkit house rules (see the imported guide): tabs/width-4/88 via oxfmt, no subsystem knowledge goes in `docs/`. + # This is NOT the Next.js you know This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + diff --git a/bun.lock b/bun.lock index 6928edd..22c8df5 100644 --- a/bun.lock +++ b/bun.lock @@ -5,45 +5,46 @@ "": { "name": "sheets", "dependencies": { - "@ai-sdk/react": "latest", - "@base-ui/react": "latest", - "@better-auth/passkey": "latest", - "@ingram-cloud/ai-sdk": "latest", - "@ingram-cloud/sdk": "latest", - "@ingram-tech/nk-auth": "latest", - "@ingram-tech/nk-db": "latest", + "@ai-sdk/react": "^4.0.54", + "@base-ui/react": "^1.7.0", + "@better-auth/passkey": "^1.6.25", + "@ingram-cloud/ai-sdk": "^2.0.0", + "@ingram-cloud/sdk": "^1.4.0", + "@ingram-tech/nk-auth": "^0.13.3", + "@ingram-tech/nk-db": "^1.4.2", "@ironcalc/wasm": "file:vendor/ironcalc-wasm", - "ai": "latest", - "better-auth": "latest", - "class-variance-authority": "latest", - "clsx": "latest", - "drizzle-orm": "latest", - "exceljs": "latest", - "lucide-react": "latest", - "next": "latest", - "pg": "latest", - "react": "latest", - "react-dom": "latest", + "ai": "^7.0.51", + "better-auth": "^1.6.25", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "drizzle-orm": "^0.45.2", + "exceljs": "^4.4.0", + "lucide-react": "^1.28.0", + "next": "^16.3.0", + "pg": "^8.22.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", "sheetkit-wasm": "file:vendor/sheetkit-wasm", - "sonner": "latest", - "streamdown": "latest", - "tailwind-merge": "latest", - "zod": "latest", + "sonner": "^2.0.7", + "streamdown": "^2.5.0", + "tailwind-merge": "^3.6.0", + "use-stick-to-bottom": "^1.1.6", + "zod": "^4.4.3", }, "devDependencies": { - "@electric-sql/pglite": "latest", - "@electric-sql/pglite-socket": "latest", - "@ingram-tech/nk-dev": "latest", - "@tailwindcss/postcss": "latest", - "@types/node": "latest", - "@types/pg": "latest", - "@types/react": "latest", - "@types/react-dom": "latest", - "drizzle-kit": "latest", - "tailwindcss": "latest", - "tsx": "latest", - "typescript": "latest", - "vitest": "latest", + "@electric-sql/pglite": "^0.5.4", + "@electric-sql/pglite-socket": "^0.2.7", + "@ingram-tech/nk-dev": "^0.10.0", + "@tailwindcss/postcss": "^4.3.3", + "@types/node": "^26.1.2", + "@types/pg": "^8.20.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "drizzle-kit": "^0.31.10", + "tailwindcss": "^4.3.3", + "tsx": "^4.23.5", + "typescript": "^7.0.2", + "vitest": "^4.1.10", }, }, }, @@ -340,8 +341,6 @@ "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], - "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], - "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.142.0", "", { "os": "android", "cpu": "arm" }, "sha512-ZiRGDutGsv1G6bL/ozy/koC0Sv39T1DqyoC4KD1DOy9ZoACm1O5UWhEK2c02Qdk+4lfLVkvFa/mQ0fm/4h1BtQ=="], @@ -1520,6 +1519,8 @@ "unzipper": ["unzipper@0.10.14", "", { "dependencies": { "big-integer": "^1.6.17", "binary": "~0.3.0", "bluebird": "~3.4.1", "buffer-indexof-polyfill": "~1.0.0", "duplexer2": "~0.1.4", "fstream": "^1.0.12", "graceful-fs": "^4.2.2", "listenercount": "~1.0.1", "readable-stream": "~2.3.6", "setimmediate": "~1.0.4" } }, "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g=="], + "use-stick-to-bottom": ["use-stick-to-bottom@1.1.6", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-z3Up8jYQGTkUCsGBnwg6/wj70KgXoW5Kz1AAc1j8MtQuYMBo6ZsdhrIXoegxa7gaMMilgQYyTohTrt3p94jHog=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], diff --git a/package.json b/package.json index 6b1c284..aa7d4d3 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "sonner": "^2.0.7", "streamdown": "^2.5.0", "tailwind-merge": "^3.6.0", + "use-stick-to-bottom": "^1.1.6", "zod": "^4.4.3" }, "devDependencies": { From f676f390bd4c1779720a9d0b9f7029af866045a9 Mon Sep 17 00:00:00 2001 From: Steve Brand Date: Fri, 28 Aug 2026 18:37:44 +0200 Subject: [PATCH 02/17] feat(auth): opt-in dev email/password sign-in --- .gitignore | 1 + README.md | 12 ++- src/app/login/login-form.tsx | 101 +++++++++++++++++++++++++- src/app/login/page.tsx | 8 +- src/components/landing/Landing.tsx | 19 ++++- src/components/landing/LandingCta.tsx | 15 ++++ src/lib/auth.ts | 4 + src/lib/dev-auth.ts | 8 ++ 8 files changed, 158 insertions(+), 10 deletions(-) create mode 100644 src/lib/dev-auth.ts diff --git a/.gitignore b/.gitignore index 5e05476..9644f4d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ tsconfig.tsbuildinfo # env .env +.env* .env.local .env*.local diff --git a/README.md b/README.md index 6ad6fb7..5108ad0 100644 --- a/README.md +++ b/README.md @@ -28,10 +28,13 @@ bun install bun run dev # PGlite (no Docker) + migrations + next dev on :3000 ``` -Google sign-in gates every page, so `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET` -are required to use the app at all; `ANTHROPIC_API_KEY` is needed only for the -agent chat. Get both from `vercel env pull`. Nothing else needs configuring -locally — `bun run dev` boots its own database. +Google sign-in gates every page by default, so `GOOGLE_CLIENT_ID`/ +`GOOGLE_CLIENT_SECRET` are required to use the app at all; `ANTHROPIC_API_KEY` +is needed only for the agent chat. For local work without Google OAuth, set +`DEV_EMAIL_PASSWORD_SIGN_IN=true` before `bun run dev` and create an account on +the login page. This option is ignored outside development. Get the remaining +secrets from `vercel env pull`. Nothing else needs configuring locally — `bun +run dev` boots its own database. ## Env @@ -43,5 +46,6 @@ locally — `bun run dev` boots its own database. | `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | Shared Ingram Google OAuth client (from the infra `platform` stack) | | `BETTER_AUTH_SECRET` | Session signing secret (dev falls back to a placeholder) | | `BETTER_AUTH_URL` | Canonical origin, `https://sheets.ingram.tech` in prod | +| `DEV_EMAIL_PASSWORD_SIGN_IN` | Set to `true` to enable Better Auth email/password sign-in and local account creation in `NODE_ENV=development` only | | `NEXT_PUBLIC_GOOGLE_PICKER_API_KEY` | Optional — API key (same Google project as the OAuth client) enabling "Browse Google Drive" via the Google Picker | | `NEXT_PUBLIC_GOOGLE_PICKER_APP_ID` | Optional — that Google project's number (Picker `setAppId`) | diff --git a/src/app/login/login-form.tsx b/src/app/login/login-form.tsx index 977ba23..702cff9 100644 --- a/src/app/login/login-form.tsx +++ b/src/app/login/login-form.tsx @@ -1,10 +1,11 @@ "use client"; import { Loader2Icon } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, type FormEvent } from "react"; import { SheetsMark } from "@/components/brand/sheets-mark"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { authClient } from "@/lib/auth-client"; // If the handoff to Google hasn't navigated away by now, something is wrong @@ -12,9 +13,16 @@ import { authClient } from "@/lib/auth-client"; // leaving "Redirecting…" disabled forever. const REDIRECT_TIMEOUT_MS = 8000; -export function LoginForm({ next }: { next: string }) { +export function LoginForm({ + next, + enableDevEmailPassword, +}: { + next: string; + enableDevEmailPassword: boolean; +}) { const [pending, setPending] = useState(false); const [error, setError] = useState(null); + const [isCreatingDevAccount, setIsCreatingDevAccount] = useState(false); const timer = useRef | null>(null); useEffect(() => { @@ -53,6 +61,27 @@ export function LoginForm({ next }: { next: string }) { } }; + const signInWithEmailPassword = async (event: FormEvent) => { + event.preventDefault(); + const form = new FormData(event.currentTarget); + const email = String(form.get("email") ?? ""); + const password = String(form.get("password") ?? ""); + const name = String(form.get("name") ?? ""); + + setPending(true); + setError(null); + try { + const result = isCreatingDevAccount + ? await authClient.signUp.email({ name, email, password }) + : await authClient.signIn.email({ email, password }); + if (result.error) throw new Error(result.error.message ?? "Sign-in failed"); + window.location.assign(next); + } catch (caught) { + setPending(false); + setError(caught instanceof Error ? caught.message : "Sign-in failed"); + } + }; + return (
@@ -80,6 +109,74 @@ export function LoginForm({ next }: { next: string }) { )} {pending ? "Redirecting…" : "Continue with Google"} + {enableDevEmailPassword ? ( + <> +
+ Local development +
+
+ {isCreatingDevAccount ? ( + + ) : null} + + + +
+ + + ) : null} {error ? (

; + return ( + + ); } diff --git a/src/components/landing/Landing.tsx b/src/components/landing/Landing.tsx index 10f7b78..5c8db9b 100644 --- a/src/components/landing/Landing.tsx +++ b/src/components/landing/Landing.tsx @@ -3,6 +3,7 @@ import { ArrowUpRightIcon } from "lucide-react"; import { SheetsMark } from "@/components/brand/sheets-mark"; import { DemoWorkbook } from "@/components/landing/DemoWorkbook"; import { LandingCta } from "@/components/landing/LandingCta"; +import { isDevEmailPasswordSignInEnabled } from "@/lib/dev-auth"; const SHEETKIT_URL = "https://github.com/ingram-technologies/sheetkit"; @@ -45,7 +46,11 @@ export function Landing({ signedIn }: { signedIn: boolean }) { sheetkit - + {signedIn ? "Open" : "Sign in"} @@ -70,7 +75,11 @@ export function Landing({ signedIn }: { signedIn: boolean }) { watch the cursor move and the cells fill in.

diff --git a/src/components/landing/LandingCta.tsx b/src/components/landing/LandingCta.tsx index 41ba6b4..9f78750 100644 --- a/src/components/landing/LandingCta.tsx +++ b/src/components/landing/LandingCta.tsx @@ -25,8 +25,10 @@ export function LandingCta({ size = "default", className, children, + enableDevEmailPassword, }: { signedIn: boolean; + enableDevEmailPassword: boolean; size?: "default" | "sm" | "lg"; className?: string; children?: React.ReactNode; @@ -58,6 +60,19 @@ export function LandingCta({ ); } + if (enableDevEmailPassword) { + return ( + + ); + } + const signIn = async () => { setPending(true); timer.current = setTimeout(() => setPending(false), REDIRECT_TIMEOUT_MS); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 4cfb324..8739073 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -3,6 +3,7 @@ import { betterAuth } from "better-auth"; import { mcp } from "better-auth/plugins"; import { pool } from "@/lib/db"; +import { isDevEmailPasswordSignInEnabled } from "@/lib/dev-auth"; import { DRIVE_FILE_SCOPE, SPREADSHEETS_SCOPE } from "@/lib/gsheets-transfer"; /** @@ -24,6 +25,9 @@ export const auth = betterAuth({ baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:3000", basePath: authBasePath, advanced: { database: { generateId: uuidGenerateId } }, + // This is intentionally enabled only for an explicit local-development + // opt-in. Better Auth also rejects its email/password endpoints when false. + emailAndPassword: { enabled: isDevEmailPasswordSignInEnabled }, socialProviders: { google: { clientId: process.env.GOOGLE_CLIENT_ID ?? "", diff --git a/src/lib/dev-auth.ts b/src/lib/dev-auth.ts new file mode 100644 index 0000000..93deb6e --- /dev/null +++ b/src/lib/dev-auth.ts @@ -0,0 +1,8 @@ +/** + * Local email/password auth is deliberately opt-in. Checking NODE_ENV as well + * as the flag means a production deployment cannot enable it accidentally by + * inheriting a development environment file. + */ +export const isDevEmailPasswordSignInEnabled = + process.env.NODE_ENV === "development" && + process.env.DEV_EMAIL_PASSWORD_SIGN_IN === "true"; From a4e3bc10b11976b1379289526b9274320d617a24 Mon Sep 17 00:00:00 2001 From: Steve Brand Date: Fri, 28 Aug 2026 18:37:44 +0200 Subject: [PATCH 03/17] fix(db): reuse one pg pool across dev reloads --- src/lib/db.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/lib/db.ts b/src/lib/db.ts index ad27fb2..398250a 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1,8 +1,18 @@ import { createDb, createPool, createQueries } from "@ingram-tech/nk-db"; +import type { Pool } from "pg"; import * as schema from "./schema"; -export const pool = createPool(); +// Turbopack may re-evaluate this module while developing. PGlite exposes one +// persistent socket connection, so a second pool causes it to terminate that +// connection. Reuse the process pool across reloads; it also keeps Better Auth +// and app queries on the same connection. +const globalForDb = globalThis as typeof globalThis & { + sheetsPool?: Pool; +}; + +export const pool = globalForDb.sheetsPool ?? createPool(); +globalForDb.sheetsPool = pool; export const db = createDb(pool, schema); export const { execute } = createQueries(pool); export { schema }; From 982254a7eef379ee6c1cc4f373065eeddf5a641e Mon Sep 17 00:00:00 2001 From: Steve Brand Date: Fri, 28 Aug 2026 18:38:00 +0200 Subject: [PATCH 04/17] feat(ui): add primitives the chat panel needs --- src/components/ui/button-group.tsx | 69 ++++++++++++++++++++++++++++++ src/components/ui/button.tsx | 5 +++ src/components/ui/collapsible.tsx | 15 +++++++ src/components/ui/scroll-area.tsx | 53 +++++++++++++++++++++++ src/components/ui/textarea.tsx | 18 ++++++++ 5 files changed, 160 insertions(+) create mode 100644 src/components/ui/button-group.tsx create mode 100644 src/components/ui/collapsible.tsx create mode 100644 src/components/ui/scroll-area.tsx create mode 100644 src/components/ui/textarea.tsx diff --git a/src/components/ui/button-group.tsx b/src/components/ui/button-group.tsx new file mode 100644 index 0000000..10672c0 --- /dev/null +++ b/src/components/ui/button-group.tsx @@ -0,0 +1,69 @@ +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; +import { Separator } from "@/components/ui/separator"; + +const buttonGroupVariants = cva( + "flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1", + { + variants: { + orientation: { + horizontal: + "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none", + vertical: + "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none", + }, + }, + defaultVariants: { + orientation: "horizontal", + }, + }, +); + +function ButtonGroup({ + className, + orientation, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function ButtonGroupText({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function ButtonGroupSeparator({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants }; diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 2136a10..f7c4b95 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -26,6 +26,11 @@ const buttonVariants = cva( sm: "h-9 rounded-md px-3", lg: "h-11 rounded-md px-8", icon: "h-10 w-10", + // 28px: the density this app's chrome already hand-rolls as + // `className="size-7"` on toolbar toggles, sheet-tab menus and + // the header's icon buttons, and the size AI Elements asks for + // by name. Still clears the WCAG 2.2 24px target-size floor. + "icon-sm": "size-7", }, }, defaultVariants: { diff --git a/src/components/ui/collapsible.tsx b/src/components/ui/collapsible.tsx new file mode 100644 index 0000000..aa204fb --- /dev/null +++ b/src/components/ui/collapsible.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"; + +const Collapsible = CollapsiblePrimitive.Root; + +function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) { + return ; +} + +function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) { + return ; +} + +export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..d911b62 --- /dev/null +++ b/src/components/ui/scroll-area.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"; + +import { cn } from "@/lib/utils"; + +function ScrollArea({ className, children, ...props }: ScrollAreaPrimitive.Root.Props) { + return ( + + + {children} + + + + + ); +} + +function ScrollBar({ + className, + orientation = "vertical", + ...props +}: ScrollAreaPrimitive.Scrollbar.Props) { + return ( + + + + ); +} + +export { ScrollArea, ScrollBar }; diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx new file mode 100644 index 0000000..6dc9587 --- /dev/null +++ b/src/components/ui/textarea.tsx @@ -0,0 +1,18 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { + return ( +