diff --git a/.gitea/workflows/build-macos.yml b/.gitea/workflows/build-macos.yml new file mode 100644 index 0000000..23899a1 --- /dev/null +++ b/.gitea/workflows/build-macos.yml @@ -0,0 +1,320 @@ +name: Build macOS Packages + +# Produces, per architecture (x86_64, arm64, universal): +# TargetPrint_--macos-.app.zip — vendored into ICCery +# TargetPrint_--macos-.dmg — installable disk image +# plus TargetPrint_--vendor-iccery.zip — macos-x86_64 / macos-aarch64 / macos-universal layout + +on: + push: + tags: + - "v*" + branches: + - main + pull_request: + workflow_dispatch: + +jobs: + build-arch: + name: Build macOS (${{ matrix.platform.name }}) + runs-on: ${{ matrix.platform.os }} + env: + XDG_CONFIG_HOME: ${{ runner.temp }}/.config + strategy: + fail-fast: false + matrix: + platform: + - name: Intel + os: macos + arch: x86_64 + vendor_dir: macos-x86_64 + - name: Apple Silicon + os: macos + arch: arm64 + vendor_dir: macos-aarch64 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Ensure workspace and git permissions + run: | + mkdir -p "${{ runner.temp }}/.config/git" + touch "${{ runner.temp }}/.config/git/ignore" + mkdir -p .git/info + touch .git/info/exclude + chmod -R u+rwX .git || true + git config --local core.excludesFile "${{ runner.temp }}/.config/git/ignore" || true + + - name: Enter project root + run: | + if [ -f macos/TargetPrint.xcodeproj/project.pbxproj ]; then + echo "PROJECT_ROOT=macos" >> "$GITHUB_ENV" + else + echo "PROJECT_ROOT=." >> "$GITHUB_ENV" + fi + + - name: Select Xcode + env: + XCODE_APP: ${{ vars.XCODE_APP }} + run: | + ROOT="${PROJECT_ROOT:-.}" + chmod +x "$ROOT/Scripts/select_xcode.sh" + "$ROOT/Scripts/select_xcode.sh" + + - name: Show toolchain + run: | + echo "HAS_FULL_XCODE=${HAS_FULL_XCODE:-unset}" + echo "DEVELOPER_DIR=${DEVELOPER_DIR:-unset}" + echo "SDKROOT=${SDKROOT:-unset}" + echo "SWIFT=${SWIFT:-unset}" + echo "xcode-select -p: $(xcode-select -p 2>/dev/null || echo unknown)" + if [ "${HAS_FULL_XCODE:-0}" = "1" ]; then + XB="${XCODEBUILD:-xcodebuild}" + "$XB" -version + else + echo "Full Xcode.app not installed; will compile with swiftc." + "${SWIFT:-$(xcrun --find swiftc)}" --version + fi + sw_vers + uname -m + + - name: Set release names + id: set_env + shell: bash + run: | + if [[ "${GITHUB_REF:-}" == refs/tags/* ]]; then + TAG="${GITHUB_REF_NAME}" + else + TAG="dev" + fi + SHORT_SHA="$(git rev-parse --short HEAD)" + PREFIX="TargetPrint_${TAG}-${SHORT_SHA}-macos-${{ matrix.platform.arch }}" + echo "TAG=${TAG}" >> "$GITHUB_ENV" + echo "SHORT_SHA=${SHORT_SHA}" >> "$GITHUB_ENV" + echo "PREFIX=${PREFIX}" >> "$GITHUB_ENV" + echo "prefix=${PREFIX}" >> "$GITHUB_OUTPUT" + + - name: Make scripts executable + working-directory: ${{ env.PROJECT_ROOT }} + run: chmod +x Scripts/*.sh + + - name: Run unit tests + working-directory: ${{ env.PROJECT_ROOT }} + run: | + if [ "${HAS_FULL_XCODE:-0}" != "1" ]; then + echo "Skipping XCTest — no Xcode.app (XCTest.framework is not part of CLT)." + exit 0 + fi + XB="${XCODEBUILD:-xcodebuild}" + "$XB" test \ + -project TargetPrint.xcodeproj \ + -scheme TargetPrint \ + -destination "platform=macOS" \ + -only-testing:TargetPrintTests \ + -jobs "$(sysctl -n hw.ncpu 2>/dev/null || echo 4)" \ + -parallelizeTargets \ + CODE_SIGNING_ALLOWED=NO \ + ONLY_ACTIVE_ARCH=YES + + - name: Build ${{ matrix.platform.arch }} .app + working-directory: ${{ env.PROJECT_ROOT }} + env: + MACOS_CODESIGN_IDENTITY: ${{ secrets.MACOS_CODESIGN_IDENTITY }} + run: | + IDENTITY="${MACOS_CODESIGN_IDENTITY:--}" + export MACOS_CODESIGN_IDENTITY="$IDENTITY" + ./Scripts/build_macos.sh "${{ matrix.platform.arch }}" Release + + - name: Package .app.zip and .dmg + working-directory: ${{ env.PROJECT_ROOT }} + env: + MACOS_CODESIGN_IDENTITY: ${{ secrets.MACOS_CODESIGN_IDENTITY }} + run: | + export MACOS_CODESIGN_IDENTITY="${MACOS_CODESIGN_IDENTITY:--}" + ./Scripts/package_release.sh "${{ matrix.platform.arch }}" "${PREFIX}" + (cd build/release-assets && shasum -a 256 "${PREFIX}.app.zip" "${PREFIX}.dmg" | tee "${PREFIX}.SHA256.txt") + + - name: Upload ${{ matrix.platform.arch }} .app (for universal lipo) + uses: actions/upload-artifact@v3 + with: + name: app-${{ matrix.platform.arch }} + path: ${{ env.PROJECT_ROOT }}/build/apps/${{ matrix.platform.arch }}/TargetPrint.app + + - name: Upload ${{ matrix.platform.arch }} packages + uses: actions/upload-artifact@v3 + with: + name: ${{ env.PREFIX }} + path: ${{ env.PROJECT_ROOT }}/build/release-assets/${{ env.PREFIX }}.* + + - name: Upload Release Assets to Gitea + if: ${{ startsWith(github.ref, 'refs/tags/') && !cancelled() }} + env: + GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SERVER_URL: ${{ github.server_url }} + REPO: ${{ github.repository }} + working-directory: ${{ env.PROJECT_ROOT }} + run: | + echo "Finding release for tag ${TAG}..." + RELEASE_RESP=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \ + "${SERVER_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}") + RELEASE_ID=$(echo "${RELEASE_RESP}" | grep -o '"id":[0-9]*' | head -n 1 | cut -d: -f2) + if [ -n "${RELEASE_ID}" ]; then + echo "Found Release ID: ${RELEASE_ID}. Uploading ${PREFIX} assets..." + for f in build/release-assets/${PREFIX}.app.zip \ + build/release-assets/${PREFIX}.dmg \ + build/release-assets/${PREFIX}.SHA256.txt; do + if [ -f "$f" ]; then + FILENAME=$(basename "$f") + echo "Uploading $FILENAME..." + curl -s -X POST \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: multipart/form-data" \ + -F "attachment=@$f" \ + "${SERVER_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}" + fi + done + else + echo "No existing release found for tag ${TAG}. Skipping asset upload." + fi + + build-universal: + name: Build macOS (Universal) + needs: build-arch + runs-on: macos + env: + XDG_CONFIG_HOME: ${{ runner.temp }}/.config + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Enter project root + run: | + if [ -f macos/TargetPrint.xcodeproj/project.pbxproj ]; then + echo "PROJECT_ROOT=macos" >> "$GITHUB_ENV" + else + echo "PROJECT_ROOT=." >> "$GITHUB_ENV" + fi + + - name: Select Xcode + env: + XCODE_APP: ${{ vars.XCODE_APP }} + run: | + ROOT="${PROJECT_ROOT:-.}" + chmod +x "$ROOT/Scripts/select_xcode.sh" + "$ROOT/Scripts/select_xcode.sh" + + - name: Set release names + shell: bash + run: | + if [[ "${GITHUB_REF:-}" == refs/tags/* ]]; then + TAG="${GITHUB_REF_NAME}" + else + TAG="dev" + fi + SHORT_SHA="$(git rev-parse --short HEAD)" + PREFIX="TargetPrint_${TAG}-${SHORT_SHA}-macos-universal" + echo "TAG=${TAG}" >> "$GITHUB_ENV" + echo "SHORT_SHA=${SHORT_SHA}" >> "$GITHUB_ENV" + echo "PREFIX=${PREFIX}" >> "$GITHUB_ENV" + echo "VENDOR_PREFIX=TargetPrint_${TAG}-${SHORT_SHA}-vendor-iccery" >> "$GITHUB_ENV" + + - name: Download Intel .app + uses: actions/download-artifact@v3 + with: + name: app-x86_64 + path: ${{ env.PROJECT_ROOT }}/build/apps/x86_64 + + - name: Download Apple Silicon .app + uses: actions/download-artifact@v3 + with: + name: app-arm64 + path: ${{ env.PROJECT_ROOT }}/build/apps/arm64 + + - name: Make scripts executable + working-directory: ${{ env.PROJECT_ROOT }} + run: chmod +x Scripts/*.sh + + - name: Lipo Universal 2 .app + working-directory: ${{ env.PROJECT_ROOT }} + env: + MACOS_CODESIGN_IDENTITY: ${{ secrets.MACOS_CODESIGN_IDENTITY }} + run: | + export MACOS_CODESIGN_IDENTITY="${MACOS_CODESIGN_IDENTITY:--}" + # upload-artifact may nest as TargetPrint.app/ or as Contents/ at the root + for arch in x86_64 arm64; do + base="build/apps/${arch}" + if [ ! -x "${base}/TargetPrint.app/Contents/MacOS/TargetPrint" ] \ + && [ -x "${base}/Contents/MacOS/TargetPrint" ]; then + mkdir -p "${base}/TargetPrint.app" + # Move bundle contents into TargetPrint.app + shopt -s dotglob + mv "${base}/Contents" "${base}/PkgInfo" "${base}/Resources" "${base}/Info.plist" \ + "${base}/_CodeSignature" "${base}/TargetPrint.app/" 2>/dev/null || true + shopt -u dotglob + fi + test -x "${base}/TargetPrint.app/Contents/MacOS/TargetPrint" + lipo -info "${base}/TargetPrint.app/Contents/MacOS/TargetPrint" + done + ./Scripts/make_universal.sh + lipo -info build/apps/universal/TargetPrint.app/Contents/MacOS/TargetPrint + + - name: Package universal .app.zip, .dmg, and ICCery vendor zip + working-directory: ${{ env.PROJECT_ROOT }} + env: + MACOS_CODESIGN_IDENTITY: ${{ secrets.MACOS_CODESIGN_IDENTITY }} + run: | + export MACOS_CODESIGN_IDENTITY="${MACOS_CODESIGN_IDENTITY:--}" + ./Scripts/package_release.sh x86_64 "TargetPrint_${TAG}-${SHORT_SHA}-macos-x86_64" + ./Scripts/package_release.sh arm64 "TargetPrint_${TAG}-${SHORT_SHA}-macos-arm64" + ./Scripts/package_release.sh universal "${PREFIX}" + ./Scripts/vendor_bundle.sh "${VENDOR_PREFIX}" + (cd build/release-assets && shasum -a 256 \ + "${PREFIX}.app.zip" "${PREFIX}.dmg" "${VENDOR_PREFIX}.zip" \ + | tee "${PREFIX}.SHA256.txt") + + - name: Upload universal packages + uses: actions/upload-artifact@v3 + with: + name: ${{ env.PREFIX }} + path: | + ${{ env.PROJECT_ROOT }}/build/release-assets/${{ env.PREFIX }}.* + ${{ env.PROJECT_ROOT }}/build/release-assets/${{ env.VENDOR_PREFIX }}.zip + + - name: Upload ICCery vendor tree + uses: actions/upload-artifact@v3 + with: + name: ${{ env.VENDOR_PREFIX }} + path: ${{ env.PROJECT_ROOT }}/build/release-assets/vendor-iccery + + - name: Upload Release Assets to Gitea + if: ${{ startsWith(github.ref, 'refs/tags/') && !cancelled() }} + env: + GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SERVER_URL: ${{ github.server_url }} + REPO: ${{ github.repository }} + working-directory: ${{ env.PROJECT_ROOT }} + run: | + echo "Finding release for tag ${TAG}..." + RELEASE_RESP=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \ + "${SERVER_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}") + RELEASE_ID=$(echo "${RELEASE_RESP}" | grep -o '"id":[0-9]*' | head -n 1 | cut -d: -f2) + if [ -n "${RELEASE_ID}" ]; then + echo "Found Release ID: ${RELEASE_ID}. Uploading universal + vendor assets..." + for f in build/release-assets/${PREFIX}.app.zip \ + build/release-assets/${PREFIX}.dmg \ + build/release-assets/${PREFIX}.SHA256.txt \ + build/release-assets/${VENDOR_PREFIX}.zip; do + if [ -f "$f" ]; then + FILENAME=$(basename "$f") + echo "Uploading $FILENAME..." + curl -s -X POST \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: multipart/form-data" \ + -F "attachment=@$f" \ + "${SERVER_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}" + fi + done + else + echo "No existing release found for tag ${TAG}. Skipping asset upload." + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d8f04a --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +build/ +dist/ +DerivedData/ +xcuserdata/ +*.xcuserstate +.DS_Store diff --git a/.grok/app-env.json b/.grok/app-env.json new file mode 100644 index 0000000..bf303ce --- /dev/null +++ b/.grok/app-env.json @@ -0,0 +1,6 @@ +{ + "VITE_AUTH_ENABLED": "false", + "deploy": { + "database": false + } +} diff --git a/.grok/brand-refs/favicon-16-x8.png b/.grok/brand-refs/favicon-16-x8.png new file mode 100644 index 0000000..1ec41a9 Binary files /dev/null and b/.grok/brand-refs/favicon-16-x8.png differ diff --git a/.grok/brand-refs/favicon-16.png b/.grok/brand-refs/favicon-16.png new file mode 100644 index 0000000..31f3fc2 Binary files /dev/null and b/.grok/brand-refs/favicon-16.png differ diff --git a/.grok/brand-refs/favicon-preview.html b/.grok/brand-refs/favicon-preview.html new file mode 100644 index 0000000..652956c --- /dev/null +++ b/.grok/brand-refs/favicon-preview.html @@ -0,0 +1,13 @@ + + + +
+
+
+
+
diff --git a/.grok/brand-refs/favicon-preview.png b/.grok/brand-refs/favicon-preview.png new file mode 100644 index 0000000..bb0e7e4 Binary files /dev/null and b/.grok/brand-refs/favicon-preview.png differ diff --git a/.grok/favicon-16.png b/.grok/favicon-16.png new file mode 100644 index 0000000..ecded0c Binary files /dev/null and b/.grok/favicon-16.png differ diff --git a/.grok/favicon-32.png b/.grok/favicon-32.png new file mode 100644 index 0000000..2339992 Binary files /dev/null and b/.grok/favicon-32.png differ diff --git a/.grok/preview.log b/.grok/preview.log new file mode 100644 index 0000000..9a74889 --- /dev/null +++ b/.grok/preview.log @@ -0,0 +1,15 @@ + +> preview +> node scripts/with-app-env.mjs vite preview + + + ╭────────── [Build Info] ─────────────╮ + │ │ + │ - Build Directory: .vercel/output │ + │ - Date: 9/7/2026, 10:13:24 AM │ + │ - Nitro Version: 3.0.260610-beta │ + │ - Nitro Preset: vercel │ + │ │ + ╰─────────────────────────────────────╯ + + ➜ Local: http://127.0.0.1:8081/ diff --git a/.grok/references/browser-qa.md b/.grok/references/browser-qa.md new file mode 100644 index 0000000..15457e5 --- /dev/null +++ b/.grok/references/browser-qa.md @@ -0,0 +1,173 @@ +# Browser QA (agent-driven only; the user is not your QA) + +`AGENTS.md` § "Execution loop" states the mandatory pass. This is the menu of +capabilities and the depth judgment around it. + +Everything here runs **in the sandbox** against `http://127.0.0.1:8080` — it is +**not** the user's Grok chat tab. Use whatever browser capability you have +**yourself**, so quality beats curl-only. + +1. **Grok browser / computer-use / MCP browser tools**, if listed — open + `http://127.0.0.1:8080`, glance at the UI, screenshot if supported. +2. **`web_fetch`** on that URL for an HTML-only check. +3. **Playwright helper (preinstalled)** — one run loads desktop **and** mobile, + screenshots both, and prints a JSON verdict. + +```bash +mkdir -p /workspace/screenshots +node scripts/browser-smoke.mjs http://127.0.0.1:8080/ /workspace/screenshots/app-builder-preview.png +# Writes app-builder-preview.png (desktop), -mobile.png, and .json (verdict). +# Then Read BOTH PNGs in one batched read if you have an image tool, and iterate if either looks wrong. +``` + +One run audits desktop (1280×800) **and** mobile (390×844): two PNGs (mobile +gets a `-mobile` suffix) plus a JSON verdict (per-viewport console/page errors, +body text, horizontal overflow) on stdout and next to the PNG (`.json`). It +writes under `/workspace/screenshots/` by default; pass an explicit path only +for a different name **under that directory**. + +4. **`agent-browser` (preinstalled)** — the interactive pass: click, type, hold + keys, read state back out. § "Interactive QA" below; it is also what the + `controls` §5c self-test runs. + +## Built output + +Serve the build with `npm run preview:restart` (loopback `127.0.0.1:8081`) and +reuse the dev verdict as a baseline — the JSON reports `divergesFromBaseline`, so +you only re-read the built screenshots when it flags. + +```bash +npm run preview:restart # built output on 127.0.0.1:8081 — QA only, never the live preview +node scripts/browser-smoke.mjs http://127.0.0.1:8081/ /workspace/screenshots/app-builder-built.png --baseline /workspace/screenshots/app-builder-preview.json +npm run preview:stop # frees :8081 when the built-output QA is done +``` + +`preview:restart` (`scripts/preview.mjs`) kills whatever holds `:8081` — the +port's owner, whoever started it — then serves the current build in the +background and returns once it answers. Use it instead of bare +`npm run preview`: `vite preview` is strictPort, so a preview left running from +an earlier build both fails a plain start and keeps serving stale output. + +## Interactive QA — `agent-browser` + +```bash +agent-browser open http://127.0.0.1:8080/ +agent-browser snapshot -i # a11y tree with @e1 refs +agent-browser click @e1 # or: find text "Start" click +agent-browser fill "#email" you@example.com # type for keystrokes +agent-browser press Enter # a tap; holding: see "Keys" +agent-browser wait --fn "window.__ready === true" # or: wait 500 (ms) +agent-browser eval "document.querySelectorAll('.card').length" +agent-browser console # or: errors (page errors) +agent-browser screenshot /workspace/screenshots/step.png +agent-browser close +``` + +**Any multi-verb flow goes in one `batch`.** Each invocation is a tool call of +its own, so a 10-verb flow costs ten round trips; `batch` runs the flow in one. +Reach for it by default and drop to single commands only for a one-off probe. +It takes the commands as a JSON array of string arrays on stdin, which also +sidesteps the shell quoting a flow with quotes in its `eval` would need: + +```bash +agent-browser batch --bail <<'JSON' +[["open","http://127.0.0.1:8080/"], + ["find","text","Add note","click"], + ["fill","#title","Grocery list"], + ["press","Enter"], + ["wait","300"], + ["eval","if (![...document.querySelectorAll('li')].some(n => n.textContent.includes('Grocery list'))) throw Error('note did not appear in the list')"], + ["screenshot","/workspace/screenshots/note-added.png"]] +JSON +``` + +That is the shape for any app: drive the thing a user would do, then assert the +result with a throwing `eval`. It exits 0 when the note lands and 1 when it does +not, so one tool call is the whole verdict. No `close` — the page is still there +for the next check. + +`--bail` stops at the first failing command; without it the later ones still +run. Either way a failed step exits non-zero, so a batch is a verdict. That is +how to keep an interactive pass cheap — one tool call instead of one per verb. +`controls` §5c is the worked example. + +**`open` once.** The page outlives the command: a later invocation, batched or +not, keeps the DOM and any `window` state you set. A second `open` re-navigates +and **discards that state**, so starting every batch with one both costs a page +load and throws away the state you just built. Open at the start of a pass and +again only after a rebuild, or to reset deliberately. + +**Match the depth of the pass to the app.** A page that only renders is done +once the smoke screenshots look right — the interactive verbs are for apps with +something to drive. Verify each behaviour once; re-run a check after you change +the code it covers, not otherwise. + +**A `✓` from `open` is not "the page loaded".** An HTTP error page reports +`✓` too (a 404 as `✓ Error response`), so assert on page content — a `find`, a +throwing `eval` — rather than trusting the tick. + +The image build asserts the core verbs stay in the CLI's subcommand list. +**Their arguments and flags are not checked** — they are upstream's, and +upstream ships ~17 releases a month, so a manual here would be stale on arrival: +if a command rejects what you typed, `agent-browser --help` decides, not +this file. + +**`@ref`s go stale.** A `@e1` is valid only for the `snapshot` that produced +it, so re-snapshot after any DOM change — navigation, a click that re-renders, +a route change — rather than reusing an old ref. It is the most common misuse. + +**`find text` is case-sensitive.** It matches a substring of an element's +trimmed text — `find text "Play" click` hits `Play now` — but `start` will not +find `Start`. Copy the label from `snapshot -i`; guessing it costs a failed +command and a re-snapshot. + +**Keys.** `press ` sends a real `event.code` (`press d` → `KeyD`) and is +the only single-key command `--help` documents; raw keystrokes at the current +focus, with no selector, are `keyboard type `. `keydown`/`keyup` exist but +are undocumented and send **`event.code === ""`**, so a game that tracks +`event.code` never sees them — do not build a check on either. Hold keys from +inside the page instead: the game's own probe (`controls` +§5b), or dispatch the event yourself, on `document.body` with `bubbles: true` +so listeners on `document` or `window` see it — dispatching on `window` itself +does not reach a `document` listener: + +```bash +agent-browser eval "document.body.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyA', key: 'a', bubbles: true }))" +``` + +**Falsy is not failure — throw.** `eval "false"` exits **0**; `eval "if (!cond) +throw Error('why')"` exits **1**. Throwing is what turns a check into a verdict +instead of a line of output you have to read yourself; `controls` §5c is the +worked example. Anything past one expression goes in an IIFE — +`eval "(() => { … })()"`, the form the image build asserts and the only one you +can re-run (a bare `const x = …` fails the second time with "already +declared") — or through `eval --stdin` with a heredoc, or `eval -b `, +when the quoting gets awkward. `eval` awaits a promise, so an `async` IIFE +works and its rejection still exits 1: that is how you time something in page +time rather than across command round-trips. + +**Output modes.** Plain output is a line per command (`eval "1+1"` prints `2`) +and is the default for a reason: `--json` wraps each one in a repeated +`lifecycle` block, measured at ~1 KB for three commands whichever three, so it +is worth it only when you are parsing. Same for +snapshots: prefer `snapshot -i | grep -i ""` or `snapshot -s "#sel"`, +and keep a bare `snapshot` for a page you cannot otherwise navigate. + +**Operating notes** (conventions, not enforcement): loopback targets and +screenshots under `/workspace/screenshots/`, the same rules as everywhere else +here; a `--session ` per flow if you ever run two at once; if a command +hangs, `agent-browser close` and retry. + +### Fallback + +`agent-browser` is the tool for interactive QA. If it fails **twice on the same +step** — daemon wedged, a command it does not support — fall back to a short +Playwright script (still installed) and say so in one line of your summary. + +## How deep to go + +Depth **beyond the mandatory smoke pass + screenshot read** is your judgment: a +landing page usually needs nothing more than that pass. For a game with WASD / +vehicles / flight, still verify control signs (A left / D right from a chase +cam) per `.grok/skills/controls/SKILL.md` — you don't have to play end-to-end, +but inverted A/D must not ship. diff --git a/.grok/references/data-and-auth.md b/.grok/references/data-and-auth.md new file mode 100644 index 0000000..de2208f --- /dev/null +++ b/.grok/references/data-and-auth.md @@ -0,0 +1,100 @@ +# Data & auth — implementation + +Read this **after** `AGENTS.md` §0.5 has already said the app needs a database +and/or sign-in. The decision (auth OFF by default, the closed trigger list, "no +migrations / no `@/lib/db` unless triggered") lives in `AGENTS.md`, not here. + +Full guides + snippets: the **`neon` skill** (database) and the **`auth` skill** +(sign-in), under `.grok/skills/`. + +## Database (`@/lib/db`, server-only) + +- `const sql = await getSql()` from `@/lib/db`. Use it **only** inside + `createServerFn` handlers / server loaders. +- Dual-mode: a regular Postgres driver (node-postgres, `pg`) when `DATABASE_URL` + is set, else a local **PGLite** fallback — so the preview always renders. +- In preview, PGLite **bootstraps at server start** (`ensureDbReady`) once the + app has migrations. Do not remove that. +- A deployed app is provisioned a real database when it ships `migrations/*.sql` + or sign-in. An app that needs one without either says so with + `"deploy": {"database": true}` in `.grok/app-env.json` — see the `neon` skill. + +## Migrations + +- `migrations/*.sql` is the single schema source: applied to **Neon on deploy** + (`npm run build` runs `npm run db:migrate`, so Vercel ships with the schema + ready) and to the **PGLite** preview automatically on startup. +- Add the app's tables as ordered files (`migrations/0002_*.sql`), not inline. +- An app that needs no database adds no `.sql` file, and then no migration runs + anywhere. +- The Better Auth schema sits outside that scope in `migrations/auth/` (neither + applier descends into it); the `auth` skill's "Turning sign-in on" copies it + up. Do not edit it. Applied files are keyed by **basename**, so a database + that already has `0001_auth.sql` will not re-run it. + +## Server functions + +`createServerFn` with input via `.validator()` — the current API on the +installed version (`.inputValidator()` is deprecated). Examples in the `neon` +and `auth` skills. + +## Auth wiring (only once §0.5 says accounts) + +- The app runs its **own** Better Auth at `/api/auth/*` and federates to the + shared Grok auth broker for **Google** and **X**. The only other supported + method is this app's own **email/password** (local Better Auth, off by + default — enable only via `src/lib/auth/email-password.ts`; **never rewrite** + `src/lib/auth/server.ts`). No other social providers, magic links, passkeys, + or OTP/phone. +- Two routes: `src/routes/api/auth/$.ts` (mounts Better Auth at `/api/auth/*`) + and `src/routes/login.tsx` (provider buttons via `signIn(providerId)`). Copy + the snippets from the `auth` skill. +- The live-preview popup at `/auth/popup` is served by the template Vite plugin + (`vite.config.ts` → `popup.server.ts`); `AGENTS.md` § "First scaffold" states + the rule about not adding a React route there. +- Read the user with `useCurrentUser()` (`@/lib/auth/use-current-user`) and gate + UI with `SignedIn` / `SignedOut` / `UserButton` (`@/lib/auth/gates`). +- Sign-in is **real even in the live preview** — it federates via a baked shared + preview client — so a visitor is signed out until they sign in. Build real + sign-in; do **NOT** scaffold demo/mock/hardcoded users. +- **Authorize every server function** with `authMiddleware` + (`@/lib/auth/middleware`): `createServerFn().middleware([authMiddleware])` + hands the handler a **verified** `context.userId` (resolved from the + same-origin session; throws when signed out). Scope **every** query by that + `user_id`. Never trust a client-sent id. + +## Turning sign-in on (at scaffold or later) + +`.grok/app-env.json` (`{"VITE_AUTH_ENABLED": "false"}`) is the switch, read by +`npm run dev` / `build` / `preview` alike through `scripts/with-app-env.mjs` — +which is why Vite is never started outside those scripts. Follow the `auth` +skill's **"Turning sign-in on"** (flag, schema, then routes) in that order: the +routes alone render the disabled branch. `npm run check:auth`, run against a +live dev server, fails when that server and the next build disagree about the +flag (exit 0 agree, 1 diverged, 2 could not observe). + +## Env + +On deploy the platform injects `DATABASE_URL` + per-app auth creds; live preview +needs neither (baked preview client, PGLite fallback). Deployed behind the gate, +signed-in Grok viewers get the app session automatically from `x-grok-identity` +(see the `auth` skill — `references/grok-identity.md`); the broker federation +covers anonymous viewers and no-gate contexts. + +## HARD RULE — connector / AppData API (backend only) + +**Never call connector or AppData APIs from frontend code.** + +| Allowed | Forbidden | +| --- | --- | +| `createServerFn({ method: "POST" }).handler` that dynamic-imports `@/lib/app-data/client.server` and calls `callTool` | Importing `@/lib/app-data/client.server` from a route component, `useEffect`, event handler, or any client module | +| UI calling that **server function** only | Browser `fetch("/__gate/app-data/…")`, `fetch` to the connectors host, or any direct CallTool from the client | +| Types/constants from `@/lib/app-data` (no network) | Putting `x-connector-access-token`, connector JWTs, or gate secrets in client state, props, or `VITE_*` env | + +**Flow (required):** browser → **this app's** `createServerFn` → **app backend** +SDK → public connectors host (`connectors.grok.me`, **auth required**) → gate. +Unauthenticated hits on the connectors host redirect to gate OIDC sign-in. + +If you need Drive / Gmail / calendar / connector data, load the **`app-data` +skill** (`.grok/skills/app-data/SKILL.md`) and follow it exactly. Do not invent +a client-side connector client. diff --git a/.grok/references/deploy-target.md b/.grok/references/deploy-target.md new file mode 100644 index 0000000..68ea777 --- /dev/null +++ b/.grok/references/deploy-target.md @@ -0,0 +1,40 @@ +# Build & deploy target + +You never trigger the deploy yourself, **but the app you build is eventually +deployed to Vercel** by the platform — so your output must build cleanly under +Vercel's process. `npm run build` must succeed and emit valid output, and code +that works under `npm run dev` but breaks a production / SSR build is a bug. +Watch for dev-only deps, server-only Node APIs run at import time, runtime +filesystem writes, and hard-coded ports / hosts / secrets. + +## A passing `npm run build` does not mean the deployed app renders + +The most common blank-deploy failure is +`Failed to load module script … MIME type "text/html"`: the built `index.html` +requests JS assets that 404 in prod, so the server returns the HTML fallback +(wrong MIME) and the page is blank. Fix the asset base path / build output so +`/assets/*` resolve, and ensure the SPA/SSR fallback doesn't shadow real asset +requests — then re-verify the served build renders. + +If you edited source after kicking off the build, re-run `npm run build` first, +then `npm run preview:restart` — it frees `:8081` before serving, so you never +smoke the previous build's output. + +## What `vite.config.ts` already does + +The workspace ships a ready `vite.config.ts` and `tsconfig.json` — don't +recreate them, and don't import a vendored `vite-tanstack-config` preset. The +config: + +- binds the dev port `0.0.0.0:8080`; +- pins `vite preview` to loopback `127.0.0.1:8081`, so the built output can + never be picked up as the user's live preview; +- gates `nitro({ preset: "vercel" })` on `command === "build" || isPreview`, so + it never runs in dev — left on in dev, nitro opens a second dev-server port, + which breaks the single-port 8080 live preview — but still serves the built + output under `vite preview`; +- mounts `grokPwaPlugin()`. + +If you edit it, preserve both port contracts, the build/preview-gated nitro +plugin **including its `serverDir: "./server"` option** (without it the deployed +app loses the Home Screen install page), and `grokPwaPlugin()`. diff --git a/.grok/references/generated-art.md b/.grok/references/generated-art.md new file mode 100644 index 0000000..c36134d --- /dev/null +++ b/.grok/references/generated-art.md @@ -0,0 +1,67 @@ +# Generated art (2D only) + +`AGENTS.md` § "Skills" states the availability rule: only call `imagine_*` tools +when they appear in your available tools list, and ship CSS / SVG / emoji / +canvas / geometric-WebGL art when they don't. This file is the pipeline detail +for when they are listed. + +## Illustration + +When the product needs illustration (heroes, empty states, textures, icons), +generate **2D** assets via the image tools — follow the **`imagine`** skill +(`imagine_text_to_image` / `imagine_image_to_image` / `imagine_text_to_video` / +`imagine_image_to_video` path-based stack; show results with `render_file`). +Image tools do **not** create 3D models; use geometry/glTF for interactive 3D +(`building-games`). + +## Game art + +- **Doctrine, not the pipeline:** for any game sprites, sheets, animations, + tiles, or UI art, load **`game-asset-core`** plus the matching specialist — + **`game-animation-frames`** (motion / loop laws), **`game-tilesets`** + (seamless tiles / transitions), **`game-character-consistency`** (turnarounds + / variants), **`game-ui-icons`** (HUD / buttons / icon sets). These cover + engine-ready defaults, blind verify, and retry discipline — **not** a + substitute for the pipeline skills below, and not a substitute for + implementing the app. +- **2D sprites / animation sheets** (characters, walk cycles, attacks, + projectiles, FX, props): run **`generate2dsprite`** — solid **`#FF00FF`** + magenta sheets + local chroma postprocess scripts. That magenta key is + **required** by the processor; do not invent a different "keyable" color on + this path. Layer `game-asset-core` (+ `game-animation-frames` / + `game-character-consistency`) for QC. +- **Exception — abstract/geometric games** (tetris, snake, pong, breakout, and + the like) are correctly rendered procedurally. Generated sprite sheets there + are a quality regression: do **not** invoke image generation for them even + when gen tools are listed. +- **2D maps / levels / prop packs** (top-down RPG, side-scroller stages, layered + maps, collision zones): follow **`generate2dmap`**. Default engine target is + browser (`raw_canvas` / Phaser), not Godot/Unity. Tileable ground/walls → also + `game-tilesets` for seamlessness checks. +- **Denser motion from video** (optional, Grok-only): **`video2dsprite`** — + `imagine_image_to_video` → ffmpeg → magenta chroma scripts. Prefer + `generate2dsprite` for crisp production sheets, and use `video2dsprite` rather + than ad-hoc ffmpeg for the sandbox execution path. + +## Share card / app identity + +A custom share card is the default: open the **`og`** skill and produce a custom +`public/og.jpg` from the app's own art before you finish. + +- It covers games of **every** kind and rendering tech (a DOM tic-tac-toe is + still a game), whimsical apps, creative tools, and brand-forward pages. Only + plain utilities (converters, CRUD trackers, admin dashboards) keep the + `og.grok.me` placeholder, and the favicon alone never satisfies this. +- Custom art is `public/og.jpg` **and** `"card": "custom"` in + `src/lib/og/site.json`. Bake infers custom from the file when the flag is + missing, but `brand-check` still requires the field. +- Games also set `"type": "x:game"` (X presents the unfurl as a game card) and + ship `public/x-banner.jpg` (50:11, 1200×264). `twitter:card=summary_large_image` + is layout, not the game signal. +- Title defaults to the host slug; `src/lib/og/site.json` is only needed when + the display name is not the slug, or the app is a game. +- The tags come from the PWA injector (`scripts/grok-pwa-shared.mjs`), which + overwrites anything in `__root.tsx`. Live preview emits the same tags (via + `X-Forwarded-Host`), and identity is baked at `vite build` so published Nitro + can inject without reading the workspace filesystem. +- Applies at build time, publish or not. diff --git a/.grok/references/hibernate-revive.md b/.grok/references/hibernate-revive.md new file mode 100644 index 0000000..62f9f1a --- /dev/null +++ b/.grok/references/hibernate-revive.md @@ -0,0 +1,44 @@ +# Hibernate, revive, and follow-up turns + +`AGENTS.md` § "`/workspace/startup.sh`" holds the rules for the file itself. +This is the surrounding lifecycle behaviour. + +## Session shapes you may land in + +- **Fresh `/workspace`** — template + `node_modules` only, no app routes, no + `startup.sh`. Scaffold, then write `startup.sh`. +- **Hibernate / revive (snapshot restore)** — the platform re-runs + `/workspace/startup.sh` if it exists. Everything else in the workspace comes + back as it was, including the app source. Your job on every turn is to leave + that file able to bring the preview back on its own. +- **Reboot / recreate** — may wipe app files back to the template. Re-scaffold + and **restore `startup.sh`** before verifying the preview. + +A revive with no `startup.sh` leaves nothing listening on `:8080`, so the user +sees an empty preview pane. + +## A `startup.sh` that satisfies the rules + +```sh +#!/bin/sh +set -eu +cd /workspace +# :8081 is QA-only — a revive must never inherit a stale built-output preview. +# Called directly, not via npm: no node_modules needed, so nothing to wait for. +node scripts/preview.mjs stop || true +if curl -sf -o /dev/null --max-time 2 http://127.0.0.1:8080/; then + exit 0 +fi +npm run dev >>/tmp/app-startup.log 2>&1 & +``` + +## Follow-up turns (multi-turn continuity) + +- Edit in place. Do not re-scaffold unless files were wiped or the change is too + big to patch cleanly. +- Vite HMR pushes source edits to the preview instantly. Restart the dev server + **only** for `vite.config` / dependency changes — and update `startup.sh` in + the same turn if the restart command changed. +- Killing the dev server blanks the user's preview mid-session. +- After edits, re-run the smoke pass; re-read the screenshots only when the edit + touched visible UI. diff --git a/.grok/references/scaffold.md b/.grok/references/scaffold.md new file mode 100644 index 0000000..3da9bcc --- /dev/null +++ b/.grok/references/scaffold.md @@ -0,0 +1,80 @@ +# First-scaffold snippets + +Copy-paste bodies for the four entry files `AGENTS.md` § "First scaffold" +requires. They match the **installed** TanStack Start: `src/router.tsx` is +resolved by a **named `getRouter` export**, and older +`createRouter`-default-export / `app/`-directory conventions are rejected by the +plugin. + +```tsx +// src/router.tsx +import { createRouter } from "@tanstack/react-router"; +import { AppErrorComponent } from "@/lib/error-component"; +import { routeTree } from "./routeTree.gen"; // generated on first dev/build + +export function getRouter() { + return createRouter({ routeTree, defaultErrorComponent: AppErrorComponent }); +} +``` + +```tsx +// src/routes/__root.tsx — the document shell +import { createRootRoute, HeadContent, Outlet, Scripts } from "@tanstack/react-router"; +import { AuthProvider } from "@/lib/auth/provider"; +import { PreviewHostBridge } from "@/components/preview-host-bridge"; +import appCss from "../styles.css?url"; + +const APP_NAME = "My App"; + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { charSet: "utf-8" }, + { name: "viewport", content: "width=device-width, initial-scale=1" }, + { title: APP_NAME }, + { name: "theme-color", content: "#000000" }, + ], + links: [ + { rel: "icon", type: "image/svg+xml", href: "/favicon.svg" }, + { rel: "stylesheet", href: appCss }, + { rel: "manifest", href: "/__grok/manifest.webmanifest" }, + { rel: "apple-touch-icon", href: "/__grok/icon-180.png" }, + ], + }), + component: () => ( + + + + + + {/* Keep this bridge — lets the Grok preview chrome drive the app; noops when not embedded. */} + + + + + + + + ), +}); +``` + +```tsx +// src/routes/index.tsx +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/")({ component: Home }); + +function Home() { + return
Hello
; +} +``` + +```css +/* src/styles.css */ +@import "tailwindcss"; + +@layer base { + button:not(:disabled), [role="button"]:not(:disabled) { cursor: pointer; } +} +``` diff --git a/.grok/skills/auth/SKILL.md b/.grok/skills/auth/SKILL.md new file mode 100644 index 0000000..bb8e99f --- /dev/null +++ b/.grok/skills/auth/SKILL.md @@ -0,0 +1,110 @@ +--- +name: auth +description: > + Add user accounts and sign-in to this TanStack Start app. Use when the app + needs authentication, sign-in, user accounts, protected routes, or per-user + data. Triggers on "auth", "login", "log in", "sign in", "sign up", "account", + "users", "authentication", "protected", "who is logged in", "current user", + "per-user". +metadata: + short-description: "Auth via the Grok broker (Google, X) or local email/password — no other methods supported" +user-invocable: false +--- + +# Auth + +This app runs its **own** [Better Auth](https://better-auth.com) at +`/api/auth/*`, federating to the shared **Grok auth broker** (`auth.grok.me`) +via the `genericOAuth` plugin. This template wires **Google** and **X**. + +**Supported sign-in methods — use ONLY these three; nothing else is supported: +Google, X, and email/password.** No other social/OAuth provider (GitHub, Apple, +Discord, …), no magic links, passkeys, OTP, phone/SMS, or anonymous sign-in. Do +not add entries to `GROK_PROVIDERS`. Method detail and the email/password switch +(edit **only** `src/lib/auth/email-password.ts`): `references/sign-in-methods.md`. +**Exception — gate viewers are signed in already; NEVER render login/re-auth +buttons for them. Connector / app-data apps: ONLY gate "Continue with Grok", +no Google/X buttons**: `references/grok-identity.md`. + +**Sign-in is OFF by default** — the template ships `.grok/app-env.json` with +`{"VITE_AUTH_ENABLED": "false"}`, so only add accounts when the ask calls for +them (AGENTS.md §0.5). Switching it on is "Turning sign-in on" below. + +**Once on, sign-in is REAL — including in the sandbox live preview.** Do **NOT** +scaffold demo/mock/hardcoded users. Preview: popup + baked preview client; +deployed: per-app client + `DATABASE_URL` + zero-click gate sign-in +(`references/prewired-and-env.md`). + +**While OFF** (`VITE_AUTH_ENABLED=false`) a **dev user** is returned so a +non-auth app renders without a signed-in visitor — dev and preview only. +Deployed, the flag is the platform's (always `"true"`), so `requireUserId` +rejects every visitor — auth-off apps use neither it nor `authMiddleware`. + +Everything is **preinstalled and pre-wired in `src/lib/auth/`** — do not +`npm install` anything; `better-auth` is the only auth package (never +`@neondatabase/*`, `@stackframe/*`, or `@clerk/*`). **Do not edit or rewrite any +file under `src/lib/auth/`** — `server.ts` least of all — except +`email-password.ts` for its one flag. Per-file map: +`references/prewired-and-env.md`. + +**`/auth/popup` is already handled by the template Vite plugin** +(`vite.config.ts` → `popup.server.ts`): it never paints the React app. **Do NOT +create `src/routes/auth/popup.tsx`** (or any React page / client OAuth at that +path) — that shows the full app inside the popup, the common failure mode. + +**Never write a `.env` / `.env.local` / `.env.example`** in this sandbox: live +preview needs **zero** env configuration and a deployed app gets its vars +injected by the platform. The knobs that exist are in +`references/prewired-and-env.md` — never expose a non-`VITE_` var to the client. + +`migrations/auth/0001_auth.sql` is the Better Auth schema — **do not edit**. It +sits outside the globbed `migrations/` directory (neither applier descends), so +it is not applied to apps without sign-in; "Turning sign-in on" copies it up. + +## Turning sign-in on + +Do all of this — the routes alone render the disabled branch: + +1. **Flag:** delete the `VITE_AUTH_ENABLED` key from `.grok/app-env.json` and + **restart the dev server**. Vite reads env at startup, so HMR will not pick + it up. `npm run dev`, `npm run build` and `npm run preview` all read that + file through `scripts/with-app-env.mjs`, so preview and the built output flip + together — never start Vite directly. +2. **Schema:** `cp migrations/auth/0001_auth.sql migrations/0001_auth.sql`, then + restart so it applies. It is tracked by basename in `_migrations`, so a + database that already has it will not re-run it. +3. **Routes:** add `src/routes/api/auth/$.ts` + `src/routes/login.tsx` — copy + both from `references/wiring.md` (the catch-all API route is what makes + `/api/auth/*` and the broker callback work). +4. **Sign out:** a login with no way out is not done — render `` + from `@/lib/auth/gates` (wires `signOut()`; hides sign-out for gate sessions). +5. **Existing data:** wrap the app's server functions in `authMiddleware` (an + auth-off app must not have been using it — see the `neon` skill). Rows from + before sign-in existed are **development data**: drop and recreate them + unless the user says otherwise — don't hand them to whoever signs in first. + +## Building on it once it's on + +- **Sign in / out:** `signIn(providerId)` and `signOut()` from + `@/lib/auth/client`; `GROK_PROVIDERS` renders the buttons. The popup, + bearer-token hand-off, and request attachment are internal — leave them alone. + Prefer `` (it handles the pending and failure states); `signOut()` + rejects when deployed if the server never confirms — catch it. Never + `authClient.signOut()`: it leaves the preview bearer token attached to every + later request, so the visitor stays signed in. +- **Reading the user:** `useCurrentUser()` is display-only (`null` means + *loading OR signed out*, so never redirect on it alone); guard on + `useCurrentUserState()`'s `isPending` instead. Gates (`SignedIn`, `SignedOut`, + `SignInGate`, `RedirectToSignIn`, `UserButton`) live in `@/lib/auth/gates`. + CTA hard rules, skeleton, and cookie-SSR zero-flash: `references/session-ui.md`. +- **Per-user data (mandatory):** every server function that touches per-user data + must use the prewired `authMiddleware` and scope every read **and** write to + `context.userId` — a Postgres driver has full DB access, so nothing else limits + the query. Keep `user_id` columns `TEXT`; never trust a client-supplied user id; + signed out, the middleware throws `UnauthorizedError` (401). Code and + disabled-mode semantics: `references/per-user-data.md`. +- **Security model:** headless broker, `__Host-` cookies + `trustedOrigins`, and + Fetch-Metadata sibling isolation are already wired — never weaken them to make + an error go away (`references/sign-in-methods.md` covers the model and the + "Invalid origin" fix). + diff --git a/.grok/skills/auth/references/grok-identity.md b/.grok/skills/auth/references/grok-identity.md new file mode 100644 index 0000000..3d5701a --- /dev/null +++ b/.grok/skills/auth/references/grok-identity.md @@ -0,0 +1,64 @@ +# Sign in with Grok (deployed apps — zero clicks) + +Behind the edge gate, every proxied request from a signed-in Grok viewer +carries an unforgeable `x-grok-identity` JWT (EdDSA, minted per request; the +gate strips any client-supplied copy). The pre-wired `gateIdentitySessions` +plugin (`src/lib/auth/gate-session.server.ts`) verifies it against the gate's +JWKS (`/__gate/identity-key`, via `src/lib/auth/gate-identity.server.ts`) and, +when the app has no session yet, materializes the Better Auth session for that +viewer automatically — no sign-in button, no redirect, no broker round-trip. +`useSession` / `useCurrentUser` simply return the Grok user. + +The broker OAuth flow is the **fallback** for anonymous/public viewers and for +contexts without the gate. The live preview gets the same zero-click identity +from the in-VM preview proxy at `http://127.0.0.1:6014` (preview tokens carry +audience `preview`); the popup mechanism remains the fallback there. + +## Never render sign-in or re-auth UI to a gate viewer + +A gate-authenticated viewer is **already signed in**, and the gate refreshes +connector tokens on every proxied request — **the platform has no re-auth +concept**. Never render "Re-auth with Grok", "Sign in again", "Refresh +session", or a standing "Continue with Grok" button: sign-in UI may appear +only in the `app-data` skill's `login` error state, after a connector call +actually returned `loginRequired: true`. + +The live preview gets the same zero-click session, so a sign-in button visible +to the owner in the preview indicates a bug (a CTA rendered while the session +check was still pending, or rendered in reaction to a data error) — fix it, +don't restyle it. For any fallback sign-in surface use `` from +`@/lib/auth/gates`: it renders sign-in UI only after the session check resolved +to no user, never during loading and never from a data error. + +Sign-out is also a no-op for a gate session — the next request re-materializes +it from `x-grok-identity`, an instant sign-back-in loop. `` +already hides its sign-out control for gate sessions; never build a custom +sign-out (or any sign-out route/handler) for gate viewers. + +## Files (pre-wired — do not edit) + +| File | Role | +|---|---| +| `gate-identity.server.ts` | Verifies the gate's `x-grok-identity` viewer JWT (EdDSA vs the gate JWKS; fail-closed). Server-only. | +| `gate-session.server.ts` | Better Auth plugin that turns a verified gate identity into the app session with zero clicks. Already registered in `server.ts`. | + +## Env (deployer-injected) + +| Var | Scope | Meaning | +|---|---|---| +| `GROK_PROJECT_ID` | server | deployed apps: enables "Sign in with Grok" (`x-grok-identity` audience check `app:`) | +| `GROK_GATE_ORIGIN` | server | gate public origin override (JWKS + issuer pin); unset → preview mode (no `GROK_PROJECT_ID`) defaults to the in-VM proxy `http://127.0.0.1:6014` (audience `preview`), deployed mode derives it from the inbound host | + +Deployed behavior: gate-authenticated viewers are signed in automatically from +`x-grok-identity`; the deployer also injects a per-app broker client + +`DATABASE_URL`, so the fallback sign-in persists identities in Postgres. + +## Connector / app-data apps: gate sign-in only + +When the `app-data` skill applies, the login page offers ONLY "Continue with +Grok" via the gate sign-in — the zero-click `x-grok-identity` session above, or +the gate-built `loginUrl` returned by a connector call. Do not wire Google/X +buttons for these apps: a broker login can mint an identity that is not the +gate viewer the connector data belongs to. The three-method rule applies to +apps without connector data. Still no new `GROK_PROVIDERS` entries, still never +edit `src/lib/auth/`. diff --git a/.grok/skills/auth/references/per-user-data.md b/.grok/skills/auth/references/per-user-data.md new file mode 100644 index 0000000..3fa8bfd --- /dev/null +++ b/.grok/skills/auth/references/per-user-data.md @@ -0,0 +1,49 @@ +# Per-user data (server-side — mandatory) + +Pair auth with the DB (see the `neon` skill). A regular Postgres driver has full +DB access, so **every** server function that touches per-user data must verify +the caller and scope rows to them. Use the prewired **`authMiddleware`**: it +resolves the same-origin session to a verified `context.userId` (and rejects +scripted cross-site/sibling requests) — no token threading: + +```ts +import { createServerFn } from "@tanstack/react-start"; +import { getSql } from "@/lib/db"; +import { authMiddleware } from "@/lib/auth/middleware"; + +export const listTodos = createServerFn({ method: "GET" }) + .middleware([authMiddleware]) + .handler(async ({ context }) => { + const sql = await getSql(); + // Type the row shape — a server fn's return must be provably serializable. + return sql<{ id: number; title: string; done: boolean }>`select id, title, done from todos where user_id = ${context.userId} order by id desc`; + }); + +// Inputs go through `.validator()` (the current API); the client passes `{ data }`: +export const addTodo = createServerFn({ method: "POST" }) + .validator((title: string) => title.trim()) + .middleware([authMiddleware]) + .handler(async ({ context, data: title }) => { + if (!title) return; + const sql = await getSql(); + await sql`insert into todos (user_id, title) values (${context.userId}, ${title})`; + }); +// mutations must scope writes too: `... where id = ${id} and user_id = ${context.userId}` +``` + +Call these from **client code** (effects, event handlers, React Query) — that's +where `Sec-Fetch-Site: same-origin` holds: + +```ts +useEffect(() => { listTodos().then(setTodos).catch(() => setTodos([])); }, []); +``` + +Semantics: signed out → the middleware throws `UnauthorizedError` (message +`"Unauthorized"`, `status` 401 — match it to send the visitor to sign-in), in the +live preview too (real auth). With auth disabled (`VITE_AUTH_ENABLED=false`) it +resolves the dev user (`"dev-user"`) in dev and preview only — the deployed flag +comes from the deployer (today always `"true"`), so deployed it rejects every +visitor — which is why an app without sign-in must not use the middleware at all +(see the `neon` skill). Keep `user_id` columns +`TEXT` (Better Auth uses text ids; the disabled dev user is `'dev-user'`). Never +trust a client-supplied user id — only the middleware / `requireUserId()` result. diff --git a/.grok/skills/auth/references/prewired-and-env.md b/.grok/skills/auth/references/prewired-and-env.md new file mode 100644 index 0000000..411024e --- /dev/null +++ b/.grok/skills/auth/references/prewired-and-env.md @@ -0,0 +1,54 @@ +# What's pre-wired, and the env vars + +## `src/lib/auth/` + +| File | Use it for | +|---|---| +| `client.ts` | Browser client. `signIn(providerId)`, `signOut()`, `authEnabled`, `GROK_PROVIDERS`. | +| `server.ts` | The Better Auth instance (server-only). **Do not edit or rewrite.** Import only from `/api/auth/$`. | +| `email-password.ts` | **Only** place to enable local email/password (`emailAndPasswordEnabled = true`). | +| `popup.server.ts` | Live-preview popup handler (server-only). Already wired by the Vite plugin — do not create a route for it. | +| `providers.ts` | `GROK_PROVIDERS` — the fixed broker upstream list (Google and X only; don't add others). | +| `use-current-user.ts` | `useCurrentUser()` / `useCurrentUserState()` React hooks. | +| `gates.tsx` | `SignedIn`, `SignedOut`, `RedirectToSignIn`, `UserButton`. | +| `middleware.ts` | `authMiddleware` for server functions → verified `context.userId`. | +| `verify.server.ts` | `requireUserId()` / `getSessionUser()` (server-only) for manual wiring. | + +## How each mode gets its credentials + +- **Live preview** (`*.grok-sandbox.com`): the app is an embedded iframe, so + sign-in opens a **popup** (a top-level redirect to the broker can't work inside + the iframe) and federates via a baked shared **preview client** + (`src/lib/auth/preview.ts`). The handler 302s straight to the broker/upstream + login; on return the popup posts the session bearer back in a tiny HTML page. + Sessions (and email/password users) persist in the app's embedded PGLite DB — + the SAME DB as app data — and, since the iframe's cookies are partitioned, + ride that bearer token; all of it lives in `src/lib/auth`. + Restarting the preview resets the DB. +- **Deployed**: the deployer injects a per-app client + `DATABASE_URL`, so + sign-in persists identities in Postgres. + +## Env vars — do **not** create a `.env` file + +**Never write a `.env` / `.env.local` / `.env.example` for auth (or anything +else) in this sandbox.** Live preview sign-in works out of the box with **zero** +env configuration: the server falls back to the baked preview client in +`src/lib/auth/preview.ts`, derives the `*.grok-sandbox.com` origin per-request, +mints a process-stable session secret, and persists sessions in embedded +PGLite. Deployed apps get `GROK_AUTH_*` / `BETTER_AUTH_*` / `DATABASE_URL` +injected by the platform — still not something you write into a file. + +Optional process-env knobs (platform / rare overrides only — **do not** put +these in a file you create): + +| Var | Where | Purpose | +|---|---|---| +| `VITE_AUTH_ENABLED` | client | `"false"` in the shipped `.grok/app-env.json` (dev user); drop the key to turn sign-in ON. Only client-visible auth flag | +| `BETTER_AUTH_URL` | server | app's own public origin; unset in preview (origin is derived per-request) | +| `BETTER_AUTH_SECRET` | server | signs this app's own sessions (process-stable fallback in preview; survives HMR) | +| `GROK_AUTH_ISSUER` | server | the shared broker (defaults to `https://auth.grok.me`) | +| `GROK_AUTH_CLIENT_ID` / `GROK_AUTH_CLIENT_SECRET` | server | per-app client (falls back to the preview client) | +| `DATABASE_URL` | server | when deployed, Better Auth persists here (preview persists to the embedded PGLite — same DB as app data) | + +Never expose a non-`VITE_` var to the client. The preview client id/secret live +server-only in `src/lib/auth/preview.ts`. diff --git a/.grok/skills/auth/references/session-ui.md b/.grok/skills/auth/references/session-ui.md new file mode 100644 index 0000000..c07df42 --- /dev/null +++ b/.grok/skills/auth/references/session-ui.md @@ -0,0 +1,133 @@ +# Reading the user, protecting routes, and preventing flicker + +## When sign-in UI may render (hard rules) + +- **Connector / owner-data apps: never gate the data fetch behind a session.** + When the data comes from the app's connector grants, the fetch runs on the + gate-injected connector token — a chain that does not involve the app + session. Fetch first; use the session for personalization only (greeting, + per-user rows). Requiring sign-in before fetching adds a click that changes + nothing. +- **A sign-in CTA may render only after the session check has RESOLVED to no + user** — never while `isPending` is true (no CTA flash on load), and never + as a reaction to a data error (the `app-data` skill's error mapping owns + those states; only its `login` kind ever shows "Continue with Grok"). +- **In preview, gate identity signs the owner in with zero clicks** — a + visible sign-in button for the owner in the preview is a bug to fix, not a + style choice (`grok-identity.md`). +- **Use `` from `@/lib/auth/gates`** — `{ children, fallback? }`: + nothing while pending, `children` when signed in, `fallback` (or the + standard provider buttons) only once signed out is known. Do not hand-roll + sign-in CTAs from `useCurrentUser()`. + +## Reading the user / protecting routes + +`@/lib/auth/use-current-user` (with auth on these reflect the REAL session, so a +preview visitor is signed out until they sign in): + +- `useCurrentUser()` → `AppUser | null` — for display. `null` means *loading OR + signed out*, so never redirect on it alone. +- `useCurrentUserState()` → `{ user, isPending }` — for guards: wait for + `isPending` to clear before treating `user: null` as signed out, or a hard + reload bounces signed-in users to sign-in. + +**State components** from `@/lib/auth/gates`: `SignedIn`, `SignedOut`, +`SignInGate` (`{ children, fallback? }` — nothing while pending, `children` +signed in, `fallback` or the standard provider buttons only once signed out is +known; prefer it over hand-rolled sign-in CTAs), `RedirectToSignIn`, +`UserButton`. (When auth is disabled via `VITE_AUTH_ENABLED=false` they apply +dev-user semantics so a non-auth app still renders.) + +```tsx +import { useCurrentUser, useCurrentUserState } from "@/lib/auth/use-current-user"; +import { RedirectToSignIn, SignedIn, SignedOut, UserButton } from "@/lib/auth/gates"; + +function Navbar() { + const user = useCurrentUser(); // display only — null may just mean "loading" + return ( + <> + {user?.displayName ?? "Guest"} + Sign in + + + ); +} + +function AccountPage() { + const { user, isPending } = useCurrentUserState(); + if (isPending) return null; // session still resolving + if (!user) return ; // client-side Navigate — not window.location + return

Welcome, {user.displayName}

; +} +``` + +Sign out with `` or `signOut()` from `@/lib/auth/client`. + +Session loading is the **same in live preview and when deployed**: wait for +`isPending` from `useCurrentUserState()` (backed by `/api/auth/get-session`). +The only live-preview difference is **how sign-in starts** (popup + bearer hand-off +instead of a full-page OAuth redirect) — not how guests vs signed-in users are +detected. Prefer `` (TanStack ``) over +`window.location.href = "/login"` so a signed-out redirect does not full-reload +the SPA. + +## Preventing auth flicker + +`useSession()` resolves on the client, so a naive UI flashes signed-out → +signed-in on load. Rules: + +1. **Gate on `isPending`, not `user` alone — and render a same-sized skeleton.** + Showing the SAME placeholder while `isPending` (server render + first client + paint) makes it one clean swap (skeleton → content) with no flash and no SSR + hydration mismatch. Don't return `null` in a slot that then grows — reserve the + space: + + ```tsx + import { useCurrentUserState } from "@/lib/auth/use-current-user"; + import { UserButton } from "@/lib/auth/gates"; + + function AuthSlot() { + const { user, isPending } = useCurrentUserState(); + if (isPending) return
; + return user ? : Sign in; + } + ``` + +2. **Guard at a layout boundary** (nav / page shell), not in leaf components that + mount/unmount — `useSession` is one shared store, so keep one stable consumer + per region instead of re-gating everywhere. + +3. **Zero-flash when deployed: SSR the session from the cookie.** On a deployed + app (and top-level navigations) the session cookie is same-origin, so the server + already knows the user on the first request — resolve it in the root route and + render the authed shell immediately: + + ```tsx + // src/routes/__root.tsx (excerpt) + import { createServerFn } from "@tanstack/react-start"; + import { createRootRoute } from "@tanstack/react-router"; + + const fetchSessionUser = createServerFn({ method: "GET" }).handler(async () => { + // Cookie path only — works when deployed / on top-level loads. + const { getSessionUser } = await import("@/lib/auth/verify.server"); + const u = await getSessionUser(); + return u ? { id: u.id, email: u.email } : null; + }); + + export const Route = createRootRoute({ + beforeLoad: async () => ({ sessionUser: await fetchSessionUser() }), + // Merge into the existing root — keep the existing head(). + // component: prefer `sessionUser` for the FIRST paint when deployed, then + // `useCurrentUserState()` for live in-page updates. + }); + ``` + + Sign-in/out navigate, so `beforeLoad` re-runs and the context stays fresh; call + `router.invalidate()` if you change auth state without navigating. + + In live preview the session often rides a bearer after popup sign-in, so cookie + SSR may still return null until the client `useSession()` runs with the bearer + attached — still gate on `isPending`, same as when deployed. + +The template already enables Better Auth's `session.cookieCache`, so `/get-session` +answers from a cookie when one is present (no DB round-trip). diff --git a/.grok/skills/auth/references/sign-in-methods.md b/.grok/skills/auth/references/sign-in-methods.md new file mode 100644 index 0000000..3ffc658 --- /dev/null +++ b/.grok/skills/auth/references/sign-in-methods.md @@ -0,0 +1,55 @@ +# Supported sign-in methods (Google, X, email/password — and nothing else) + +Use **only** these three — no other method is supported: + +- **Google** and **X** — federated through the Grok broker (pre-wired here). The + broker federates these two upstreams and nothing else, so do **not** add entries + to `GROK_PROVIDERS` beyond them (the broker rejects an unknown `idp`). +- **Email + password** — this app's OWN Better Auth, persisted in your database + (never the broker, never mocked). Better Auth is DB-backed in BOTH modes — real + Postgres when deployed and the embedded PGLite in the sandbox preview — so + email/password accounts are stored and survive across requests, **in preview + too**. It's off by default. Enable it by editing **only** + `src/lib/auth/email-password.ts`: + + ```ts + // src/lib/auth/email-password.ts + export const emailAndPasswordEnabled = true; // was false + ``` + + **Do not edit or rewrite `src/lib/auth/server.ts`** (or any other file under + `src/lib/auth/` except `email-password.ts` for this flag). That file is + pre-wired; "fixing" it by regenerating Better Auth config breaks live-preview + sign-in. + + The pre-applied schema already has the `account.password` column — no migration + needed. Then build sign-up / sign-in forms with `authClient.signUp.email(...)` + and `authClient.signIn.email(...)` from `@/lib/auth/client`. + + **Do not** add `emailAndPassword` as a plugin entry (that is a syntax/type + error). **Do not** invent a new Better Auth config. + + If sign-up/sign-in returns **"Invalid origin"**, do **not** disable CSRF and + do **not** edit `server.ts`. The template's `trustedOrigins` already covers + `*.grok-sandbox.com` and local loopback on port 8080 (`localhost` / + `127.0.0.1` / `[::1]`). Open the app at one of those origins (not a random + host/port). + +Do **NOT** add or use anything else: no other social / OAuth providers (GitHub, +Apple, Discord, Microsoft, Facebook, …), and no magic links, passkeys, one-time +codes / OTP, phone / SMS, or anonymous sign-in. + +## Security model (already handled — don't undo it) + +- **Headless broker**: the broker offers the upstream sign-in methods + and holds their shared secrets; this app only holds its own per-app client + id/secret and names the upstream it wants via each provider's `idp` hint. The + broker forwards straight to Google/X. Users never see the broker. +- **`__Host-` cookies + `trustedOrigins`**: a sibling `*.grok.me` app can't toss a + `Domain=.grok.me` cookie, and Better Auth rejects cross-origin `/api/auth` + calls. +- **Sibling isolation**: `authMiddleware` rejects scripted cross-site/same-site + requests (Fetch-Metadata), so a sibling can't ride this app's session cookie + into its server functions. +- The upstream Google/X tokens live only on the broker; this app only ever gets a + broker-issued identity and mints its own local session. diff --git a/.grok/skills/auth/references/wiring.md b/.grok/skills/auth/references/wiring.md new file mode 100644 index 0000000..c2331be --- /dev/null +++ b/.grok/skills/auth/references/wiring.md @@ -0,0 +1,71 @@ +# Wiring the routes (do this once) + +**Live-preview popup is PRE-WIRED — do not create it.** +`signIn` opens `/auth/popup`; the template Vite plugin +(`authPopupPlugin` in `vite.config.ts`) serves it via `popup.server.ts`. +**Never** add `src/routes/auth/popup.tsx` (or any React page / client OAuth at +that path). Doing so loads the full app shell in the popup ("the app opened +instead of Google") — that is always wrong. + +**1. Mount Better Auth** — create the catch-all API route (this is what makes +`/api/auth/*` work; the broker's OAuth callback lands here): + +```ts +// src/routes/api/auth/$.ts +import { createFileRoute } from "@tanstack/react-router"; +import { auth } from "@/lib/auth/server"; + +export const Route = createFileRoute("/api/auth/$")({ + server: { + handlers: { + GET: ({ request }) => auth.handler(request), + POST: ({ request }) => auth.handler(request), + }, + }, +}); +``` + +**2. Add a sign-in page** — buttons that kick off the broker flow. Import from +`@/lib/auth/client`. With the flag on, `authEnabled` is true in preview and +deployed, so the buttons show and work in the live preview; the `else` branch +shows while auth is still disabled (`VITE_AUTH_ENABLED=false`): + +```tsx +// src/routes/login.tsx +import { createFileRoute } from "@tanstack/react-router"; +import { GROK_PROVIDERS, authEnabled, signIn } from "@/lib/auth/client"; + +export const Route = createFileRoute("/login")({ component: Login }); + +function Login() { + return ( +
+
+

Sign in

+ {authEnabled ? ( + GROK_PROVIDERS.map((p) => ( + + )) + ) : ( +

Sign-in is disabled.

+ )} +
+
+ ); +} +``` + +`RedirectToSignIn` sends signed-out users to `/login` by default (override with +``). Style the page however you like — see +the `design-ui` skill. + +That's it — call `signIn(providerId)` from your sign-in buttons. The popup, +bearer-token hand-off, and request attachment are all inside `src/lib/auth` + +the Vite plugin; leave them alone. diff --git a/.grok/skills/building-games/SKILL.md b/.grok/skills/building-games/SKILL.md new file mode 100644 index 0000000..0c6b3cf --- /dev/null +++ b/.grok/skills/building-games/SKILL.md @@ -0,0 +1,213 @@ +--- +name: building-games +description: > + Build browser games and interactive/canvas/3D experiences in this TanStack + Start + React app. Use for any game, simulation, or WebGL/Canvas experience — + 2D or 3D, single-player. Covers the game loop & timing, 3D orientation/camera + conventions, collision, performance, assets, audio, save, game feel, and + per-genre playbooks. For WASD / vehicle / flight **input signs and inverted + A/D**, open the **`controls`** skill — do not rely on this file or racing-kart + alone. Triggers on "game", "minecraft", "fps", "platformer", "racing", + "tetris", "snake", "shooter", "3d", "three.js", "canvas", "voxel", "physics". +metadata: + short-description: "Browser games: loop, 3D orientation, camera, perf, assets, genres" +user-invocable: false +--- + +# Building Games + +Build a **playable, correct** browser game — not a static screenshot. A game is +just a React route with a `` (or `` for R3F) plus DOM overlay UI. +Style the overlay (start screen, HUD, menus) with the **`design-ui`** skill; this +skill owns the gameplay loop and world. + +**Controls / inverted A/D:** open **`.grok/skills/controls/SKILL.md`** **before** +writing WASD, steering, or flight input. Vehicle/flight demos often ship with +A/D flipped if you only read this file or a single genre playbook. + +**Scope note — single-player, bots, or small P2P co-op:** +- Ship **single-player** or **single-player + AI/bots** by default. +- **2–8 player co-op / casual realtime** (shared cursors, party games, casual + action among friends) is supported — use the **`multiplayer-p2p` skill** + (WebRTC mesh, signaled at `/api/rtc`). Read its trust model first. +- P2P is the only supported multiplayer right now. Do not half-build sockets + that can’t connect. + +**References (load on demand):** +- **`controls` skill** (`../controls/`) — **required** for movement/steer/flight: + player-visible A/D, inverted-steer anti-pattern, flight ailerons, mandatory + self-test + `window.__controlsTest`. Not optional for vehicles/planes. +- `references/threejs-foundational.md` — the deep 3D/loop/perf reference. Read for 3D. +- `references/3d-libs.md` — three.js + @react-three/fiber + drei + rapier usage. +- **`threejs` skill** (`../threejs/`) — official full Three.js + TSL API dump + (`llms-full.txt`). Load for advanced materials/shaders/WebGPU/loaders; not for + simple 2D canvas games. +- `references/babylon.md` — Babylon.js, the batteries-included 3D engine alternative. +- `references/phaser.md` — Phaser 3, the default engine for 2D games. +- `references/ecs-architecture.md` — entity-component-system structure for larger games. +- `references/genres/*.md` — per-genre playbooks (fps, platformer-2d, racing-kart, + puzzle-match3-tetris, voxel-minecraft, endless-runner, topdown-twin-stick, + tower-defense, board-card-chess). Genre files **do not** replace **`controls`**. +- `references/game-feel-juice.md`, `input.md`, `audio.md`, `collision-physics.md`, + `save-persistence.md`, `procedural-generation.md`, `ai-pathfinding.md`. +- **`game-asset-core`** (+ `game-animation-frames` / `game-tilesets` / + `game-character-consistency` / `game-ui-icons`) — engine-ready 2D art defaults + and verification when generating sprites, sheets, tiles, or UI (see §6). + +Pick the specific genre/topic reference for the build; this file is the universal +core for loop/world. **Input signs → `controls`.** **2D game art → `game-asset-core`.** + +--- + +## 1. Game loop & timing (the #1 correctness issue) + +- Drive the loop with the engine's RAF loop (`renderer.setAnimationLoop`, R3F + `useFrame`, or `requestAnimationFrame` for 2D canvas). **Never** `setInterval`/ + `setTimeout`/`Date.now()` for game timing. +- **Scale ALL movement/animation by delta time** (seconds) so speed is frame-rate + independent (60Hz vs 144Hz). Compute delta **once per frame** and reuse it. + - three.js: use `THREE.Timer` (not `Clock` — `Clock.getDelta()` returns ~0 on a + second call in the same frame, a classic freeze bug). +- **Cap delta** (`min(delta, 0.1)`) so a backgrounded tab doesn't teleport things. +- **Fixed timestep for physics/gameplay:** accumulate delta and step simulation at + a fixed rate (e.g. 1/60) while rendering at display rate — prevents tunneling and + non-determinism. + +## 2. Controls (delegate to the `controls` skill) + +**Open `.grok/skills/controls/SKILL.md` before implementing any WASD / steer / +flight code.** That skill is the source of truth for: + +- Player-visible **A = left / D = right** (chase cam, while moving forward) +- Why **`KeyA → steer−` + `yaw += steer * +rate` inverts** (most common bug) +- Vehicle vs FPS (strafe ≠ steer), fixed-wing ailerons, heli/drone notes +- Mandatory self-test + `window.__controlsTest` probe + +Do **not** treat `genres/racing-kart.md` as the only place steer signs live — +planes, jetskis, and mechs never open it. + +**Short reminder (full detail in `controls`):** + +``` +// Vehicle yaw body (chase cam): A must increase yaw with this basis +forward = (-sin(yaw), 0, -cos(yaw)) +// KeyA → steer = +1; yaw += steer * turnRate * speedFactor * dt +// WRONG (ships inverted): KeyA → steer = -1; yaw += steer * turnRate * dt +``` + +- **Pointer lock:** mouse-look only — implement WASD yourself; click-to-play + overlay; dismiss on lock. +- Track keys with held state + **dt**; unify devices via `references/input.md`. +- **Finish:** run the `controls` skill checklist. Screenshot-only is insufficient + for vehicles/flight. + +## 3. 3D orientation & world objects (the "sideways/backwards" bugs) + +- three.js is **right-handed, +Y up**: +X right, +Y up, +Z toward viewer. **Meshes + face +Z; cameras look −Z** (the classic "camera backwards" gotcha). +- Primitives like `Cone`/`Cylinder` point **+Y** by default → rotate to align a + tip with forward (`geo.rotateX(Math.PI/2)`). +- **Orienting a mesh to face `forward`** (meshes face **+Z**): simplest correct way + is `mesh.lookAt(mesh.position.clone().add(forward))`. To build the basis by hand, + set the **+Z column to `forward`** and choose the x-axis that keeps it a *proper* + right-handed rotation (`det = +1`): + `xAxis = normalize(cross(up, forward))`, then `makeBasis(xAxis, up, forward)`. + Note this is `cross(up, forward)` — **not** the movement `right = cross(forward, up)` + from §2. Targeting +Z (instead of a camera's −Z) flips the x-axis sign so that + `xAxis × up = forward`; the frame stays right-handed (no mirroring). Do **not** use + `makeBasis(xAxis, up, -forward)` for a mesh — that targets −forward (the *camera* + convention) so the mesh faces **backwards**. +- **Orienting a camera** to look along `forward` (cameras look **−Z**): set the +Z + column to `-forward` — `xAxis = normalize(cross(forward, up))`, then + `makeBasis(xAxis, up, -forward)` (or just `camera.lookAt(target)`). +- Keep a consistent world `up` so objects stay upright; only rotate flat primitives + to stand up. +- Verify glTF import orientation; debug with `AxesHelper`/`ArrowHelper`. Upright + self-test: characters stand on the ground plane, not lying/sunk. + +## 4. Camera must agree with movement + +- Keep a **dedicated `moveForward`/`moveRight`** for movement, computed once and + never mutated by camera code (aliasing a shared temp vector makes camera and + movement disagree — a real repro bug). +- Third-person follow: `desired = playerPos + up*height + moveForward*(-followDist)`; + lerp the camera toward it (use exp-based smoothing, delta-scaled), `lookAt(player)`. +- Isolate-the-layer debug order: (1) keys register → (2) movement signs correct → + (3) camera agrees. Fix in that order. + +## 5. Performance + +- **Minimize draw calls** (`renderer.info.render.calls`, target <100): share + materials, `InstancedMesh`/`BatchedMesh` for repeated objects, atlases. +- **Dispose GPU resources yourself** (`geometry/material/texture.dispose()`) on + level change — three.js does not GC them. **Pool** bullets/enemies/particles. +- No per-frame allocations (reuse temp vectors). Compress textures; LOD for distance. + +## 6. Assets (avoid the generated-photo trap) + +- **Interactive 3D elements** (weapon viewmodels, characters, props, projectiles) + → build from **3D geometry / glTF**, not a generated image. A flat photorealistic + JPG of a gun-in-hands used as an FPS viewmodel looks wrong, can't animate, and + (JPG has **no alpha**) renders as a black box. Parent a real 3D viewmodel to the + camera as an overlay render layer. +- Reserve image generation for **flat 2D** assets only (textures, sky/menu + backgrounds, 2D sprites, UI art). **Never** use a generated photo as a 3D mesh, + viewmodel, or character substitute — build those in 3D geometry / glTF. + Set `crossOrigin="anonymous"` on images drawn to canvas/textures. + See the **`imagine`** skill (2D only — image tools cannot produce real 3D). +- **Engine-ready game art doctrine** → open **`game-asset-core`** + (`../game-asset-core/`) for defaults + blind verify + retry discipline, then the + matching specialist: **`game-animation-frames`** (loop / motion laws), + **`game-tilesets`**, **`game-character-consistency`**, **`game-ui-icons`**. + These are **QC/doctrine**, not the export pipeline. Do not ship stick-figure + placeholders when real art is expected. +- **2D game sprites / animation sheets** → run **`generate2dsprite`** + (`.grok/skills/generate2dsprite/SKILL.md`): solid **`#FF00FF`** magenta + `imagine_text_to_image` sheets + chroma postprocess scripts (magenta is required for the + processor). Wire transparent PNGs/GIFs into Canvas/Phaser. Still apply + **`game-asset-core`** (+ animation/character specialists when relevant). +- **2D maps / levels / prop packs** → open **`generate2dmap`** + (`.grok/skills/generate2dmap/SKILL.md`). Prefer foundation-only base + separate + props/collision for playable maps. Browser default: `raw_canvas` / Phaser. + Tileable ground/walls → also **`game-tilesets`** for 2×2 seam checks. +- **Optional denser locomotion** → run **`video2dsprite`** (Grok + `imagine_image_to_video` + sandbox scripts; magenta base). Prefer `generate2dsprite` + for crisp production heroes. Use **`game-animation-frames`** for loop/flip-test + laws; prefer **`video2dsprite`** over ad-hoc ffmpeg-only harvest in this + sandbox. + +## 7. Audio, save, feel +- **Audio**: unlock `AudioContext` on the first user gesture (tap-to-start) or iOS + is silent; re-resume on `visibilitychange`. (`references/audio.md`) +- **Save**: `localStorage`/IndexedDB with a `version` field + migrations. +- **Juice**: screen shake, hit-stop, eased tweens, particles — cheap, huge + perceived-quality lift. Keep presentation separate from simulation. + +## 8. Mobile +- Distinguish canvas buffer size from CSS size; respect `devicePixelRatio`. +- `touch-action: none`, letterbox-fit to a base resolution, handle orientation. +- Touch controls (virtual joystick + action buttons), ≥44px targets. + +--- + +## Stack / engine choice +- **3D → three.js**, ideally via **@react-three/fiber + drei** (fits the React + app; drei gives pointer-lock/controls/loaders) + **@react-three/rapier** for + physics/character controllers. See `references/3d-libs.md`. +- **2D →** native Canvas 2D is enough for snake/tetris/flappy/platformer; reach for + Phaser only when the genre needs it. +- These game deps are **not preinstalled** — `npm install` them (and make sure they + land in `package.json` so the Vercel build has them). + +## Finish criteria (before "done") +- Loads with **no console errors**; visible gameplay (not a blank canvas). +- **`controls` skill self-test passed** (A = left / D = right from chase cam + while moving forward; flip one sign if inverted). Not screenshot-only. +- 3D upright & camera-agrees self-tests pass (§3, §4). +- Runs on mobile viewport with touch controls. +- Production build (`npm run build`) renders the built output, not just dev. +- **Share / X card:** open the **`og`** skill — custom `public/og.jpg` **and** + `"type": "x:game"` in `src/lib/og/site.json`. X uses `og:type="x:game"` + to present the unfurl as a game card; do not use `twitter:card` or invent + `x:type` for this. `browser-smoke` / `brand-check` warn when canvas apps omit + the `site.json` field. diff --git a/.grok/skills/building-games/references/3d-libs.md b/.grok/skills/building-games/references/3d-libs.md new file mode 100644 index 0000000..d3bf27a --- /dev/null +++ b/.grok/skills/building-games/references/3d-libs.md @@ -0,0 +1,64 @@ +# 3D libraries for this stack (three.js + React Three Fiber + drei + rapier) + +The app is React/TanStack. The idiomatic way to do 3D here is **React Three +Fiber (R3F)** — write three.js as React components inside a `` on a +route. It deploys like any other page. + +> These are **not preinstalled**. Install and confirm they land in `package.json`: +> `npm i three @react-three/fiber @react-three/drei @react-three/rapier` +> (+ `npm i -D @types/three`). If the deploy build has no three, the game is blank. + +## When to use what +- **three.js** — the engine. Raw three is fine for a self-contained canvas; prefer + R3F when the game has React UI/state around it. +- **@react-three/fiber** — renders three as React; `useFrame((state, delta) => …)` + is your game loop (delta is seconds — scale movement by it, see SKILL §1). +- **@react-three/drei** — helpers so you don't hand-roll bug-prone code: + ``, `useKeyboardControls`, ``, + `useGLTF`, ``, ``, ``. +- **@react-three/rapier** — physics + **character controller** (capsule + + autostep + snap-to-ground). Use for FPS/platformer movement and collisions + instead of hand-rolled raycasts. ``, ``, `useRapier`. + +## Minimal R3F shape +```tsx +import { Canvas, useFrame } from "@react-three/fiber"; +import { PointerLockControls, useKeyboardControls } from "@react-three/drei"; + +function Player() { + useFrame((_, delta) => { + const d = Math.min(delta, 0.1); // cap delta (SKILL §1) + // move with a dedicated moveForward/moveRight basis (SKILL §2/§4) + }); + return null; +} + +export default function Game() { + return ( + + {/* scene */} + {/* mouse-look ONLY — implement WASD yourself */} + + + ); +} +``` + +## Gotchas (map to the SKILL universals) +- **Controls:** drei `PointerLockControls` is mouse-look only — WASD is yours + (SKILL §2). Gate `.lock()` behind a "click to play" overlay and dismiss it on lock. +- **Orientation:** meshes face +Z, camera looks −Z; right-handed +Y up (SKILL §3). +- **Delta:** `useFrame` delta is seconds; cap it; fixed-step physics via rapier's + own stepping. Don't read `THREE.Clock.getDelta()` twice a frame. +- **Perf:** ``/`InstancedMesh` for repeats; dispose on unmount (R3F + auto-disposes objects it created, but not manual textures/loaders — dispose those). + Set `dpr={[1, 2]}` to cap retina cost. +- **Mobile:** R3F sets pixel ratio via `dpr`; still add touch controls + `touch-action:none`. + +## Alternatives +- **Babylon.js** (`references/babylon.md`) — batteries-included 3D (Havok physics, + inspector, SceneOptimizer). Viable if you want an all-in-one engine, but it's a + separate paradigm from React; default to three/R3F for consistency with the app. + +Sources: R3F docs (https://r3f.docs.pmnd.rs), drei (https://github.com/pmndrs/drei), +react-three-rapier (https://github.com/pmndrs/react-three-rapier), three.js docs. diff --git a/.grok/skills/building-games/references/ai-pathfinding.md b/.grok/skills/building-games/references/ai-pathfinding.md new file mode 100644 index 0000000..80232c6 --- /dev/null +++ b/.grok/skills/building-games/references/ai-pathfinding.md @@ -0,0 +1,122 @@ +# Game AI & Pathfinding (A* on grids, steering, FSM, behavior trees, navmesh) + +Consolidated from Red Blob Games (the canonical A*/grid reference), Craig Reynolds' steering work, and Game AI Pro / behavior-tree canon (see Sources). Focus: what an AI builder needs so enemies/agents **move sensibly and don't jitter, get stuck, or tank the frame rate.** + +--- + +## 1. Pathfinding: A* on a grid (the default) + +**A\*** finds the shortest path by expanding the node that minimizes `f = g + h`, where `g` = cost so far and `h` = heuristic estimate to goal. It's Dijkstra + a goal-directed heuristic. + +Rules: +- **Heuristic must be admissible** (never overestimate true cost) or A* may return a non-optimal path. + - **4-directional grid → Manhattan distance** `|dx|+|dy|`. + - **8-directional grid → octile/Chebyshev distance** (diagonal cost ≈ 1.414). Using Manhattan on an 8-dir grid overestimates → wrong paths; using Euclidean on a 4-dir grid underestimates → slow. +- **Use a binary heap / priority queue** for the open set. A linear scan of the open set is the #1 A* perf bug — turns O(E log V) into O(V²) and stutters on big maps. +- **Track a closed set / best-g per node** so you don't reprocess nodes; update `g` if a cheaper path to a node is found. +- **Weighted tiles:** give terrain costs (mud=5, road=1) via `g`; A* naturally routes around expensive terrain. +- **Diagonal corner-cutting:** forbid diagonal moves that clip through two wall corners, or agents visually cut through walls. +- **Reconstruct the path** by following `cameFrom` parents from goal back to start, then reverse. + +Related tools (Red Blob Games): +- **Breadth-First Search / Dijkstra maps ("flow fields"):** when *many* agents chase *one* goal (tower defense, RTS), compute one BFS/Dijkstra field from the goal and have every agent follow the gradient — far cheaper than one A* per agent. +- **Greedy Best-First** is faster but not optimal; A* is the balanced default. +- **JPS (Jump Point Search)** optimizes A* on uniform-cost grids. + +Libraries: **PathFinding.js** (grid A*/JPS/BFS, easy), or roll your own for control. + +**Path-following gotchas:** smooth the raw grid path (string-pulling / line-of-sight simplification) so agents don't zig-zag along cell centers; recompute paths sparingly (on target move / periodically, not every frame); handle "no path exists" explicitly. + +--- + +## 2. Steering behaviors (smooth local movement) + +A* gives *where* to go; **steering** (Craig Reynolds) makes agents *move there naturally* by accumulating steering forces on velocity: +- **Seek / Arrive** — move toward a target; Arrive decelerates within a slowing radius (prevents overshoot/orbit). +- **Flee / Evade** — away from a threat. +- **Pursuit** — seek the target's *predicted future* position. +- **Wander** — smooth random roaming (jitter a point on a circle ahead), not teleporting random targets. +- **Obstacle avoidance** — steer around obstacles detected by a look-ahead feeler. +- **Separation / Alignment / Cohesion** = **Flocking (boids)** for crowds/schools/swarms. +- **Path following** — follow a computed A* path smoothly rather than snapping cell-to-cell. + +Rules: **combine forces** (weighted sum or priority), **clamp to max force and max speed**, apply with **delta time**. Steering handles the smooth "how"; A*/flow fields handle the strategic "where." Use both together — A* alone looks robotic; steering alone gets stuck on walls. + +--- + +## 3. Finite State Machines (FSM) — decision-making for simple agents + +- Agent is in exactly one **state** (Idle, Patrol, Chase, Attack, Flee); **transitions** fire on conditions (saw player → Chase; lost player → Patrol; low HP → Flee). +- Each state has `enter()`, `update(dt)`, `exit()`. Keep transition logic centralized/table-driven. +- Great for enemies with a handful of behaviors. **Downside:** transitions explode combinatorially (n states → up to n² transitions) — becomes spaghetti past ~6–8 states. +- **Hierarchical FSMs** (states containing sub-states) tame some of that. + +--- + +## 4. Behavior Trees (BT) — scalable decision-making + +When FSMs get unwieldy, use a **behavior tree**: a tree of nodes evaluated each tick, each returning **Success / Failure / Running**. +- **Composites:** **Sequence** (run children in order, fail-fast — "AND"), **Selector/Fallback** (try children until one succeeds — "OR"), **Parallel**. +- **Decorators:** Inverter, Repeat, Cooldown, Succeeder, condition guards. +- **Leaves:** Actions (MoveTo, Attack, Reload) and Conditions (IsPlayerVisible, HasAmmo). +- **`Running` is the key concept:** long actions (walking a path) return `Running` and resume next tick — don't block. +- Far more **modular/reusable/authorable** than FSMs; the industry standard for complex NPCs. Consider a lib (behaviortree.js) or a small hand-rolled implementation. +- **Utility AI / GOAP** are alternatives for emergent/goal-driven behavior at higher complexity. + +**Rule:** simple enemies → FSM; complex/multi-goal NPCs → behavior tree. Don't build a BT framework for a single 3-state slime. + +--- + +## 5. Navmesh basics (3D / open spaces) + +- Grids are natural for tile games; for **open 3D/continuous** worlds, a **navigation mesh** (convex polygons covering walkable surfaces) is more efficient and gives smoother paths than a dense grid. +- Run A* over the polygon graph (portal edges), then **string-pull ("funnel algorithm")** through polygon portals for a smooth, natural path. +- In JS: **recast-navigation-js** (WASM port of industry-standard Recast/Detour) bakes navmeshes from level geometry and provides crowd/agent avoidance; three-pathfinding for a simpler Three.js navmesh follower. +- Navmeshes handle multi-level geometry (ramps, bridges) that flat grids can't; support off-mesh links (jumps, ladders). + +--- + +## 6. Performance & correctness rules +- **Don't A* every agent every frame.** Cache paths; recompute on target movement or a throttled interval; **time-slice** pathfinding across frames or use a **web worker** for big searches so the main thread doesn't hitch. +- **Flow field / Dijkstra map** when many agents share one goal — one computation for all. +- **Binary heap** open set, **typed arrays** for large grids, reuse buffers (no per-search allocation churn). +- **Run AI on a fixed timestep** (decoupled from render) for determinism/netcode; often at a lower rate than render (e.g. 10–20Hz for decisions) with steering interpolating between. +- **Stagger** AI updates across agents (LOD-AI): distant/off-screen agents think less often. +- Handle **dynamic obstacles**: mark blocked cells / re-path when the map changes; use local steering avoidance for moving agents. + +--- + +## 7. Bug-prevention checklist +- **Non-admissible heuristic** (Euclidean on 4-dir, Manhattan on 8-dir) → non-optimal or slow paths; match heuristic to movement. +- **Linear open-set scan** → severe stutter on big maps; use a binary heap. +- **A* per agent per frame** → frame drops; cache/throttle/flow-field/worker. +- **Following raw grid path** → robotic zig-zag; smooth/string-pull and add steering. +- **No Arrive/slowing radius** → agents overshoot and orbit the target. +- **Corner-cutting diagonals** → agents clip through wall corners; forbid unsafe diagonals. +- **FSM sprawl** → unmaintainable transitions; switch to a behavior tree. +- **Blocking actions in a BT** → tree stalls; return `Running` for long actions. +- **Ignoring "no path"** → crashes/agents freeze; handle unreachable goals explicitly. +- **Not re-pathing on map change** → agents walk into new walls; invalidate cached paths. +- **AI tied to render frame rate** → non-deterministic/uneven; fixed timestep, staggered. + +--- + +## Defaults to apply +- **Default AI stack:** **A\* on a grid** (binary-heap open set, movement-matched heuristic, weighted tiles) for "where," **steering behaviors** (seek/arrive/avoid/separation) for smooth "how," and an **FSM for simple enemies / behavior tree for complex NPCs** for "what to do." +- **Use flow fields (BFS/Dijkstra map) when many agents chase one target** (tower defense/RTS) — big perf win over per-agent A*. +- **For 3D/open worlds, generate navmeshes via recast-navigation-js**; smooth paths with the funnel algorithm. +- **Bake in the perf guardrails**: cache paths, throttle/time-slice/worker pathfinding, run AI on a fixed lower-rate tick with steering interpolation, and stagger distant agents. Always handle "no path found." + +--- + +## Sources +- Red Blob Games — Introduction to A* (canonical, interactive): https://www.redblobgames.com/pathfinding/a-star/introduction.html ; A* Implementation Guide: https://www.redblobgames.com/pathfinding/a-star/implementation.html +- Red Blob Games — Grids/heuristics & "Pathfinding for Tower Defense" (flow fields): https://www.redblobgames.com/pathfinding/tower-defense/ ; grid pathfinding tricks: https://www.redblobgames.com/pathfinding/grids/algorithms.html +- Craig Reynolds — "Steering Behaviors For Autonomous Characters": https://www.red3d.com/cwr/steer/ ; Boids: https://www.red3d.com/cwr/boids/ +- Amit Patel / Red Blob — heuristics guide: https://theory.stanford.edu/~amitp/GameProgramming/Heuristics.html +- PathFinding.js (grid A*/JPS/BFS): https://github.com/qiao/PathFinding.js +- Jump Point Search (Harabor & Grastien): https://harablog.wordpress.com/2011/09/07/jump-point-search/ +- Behavior Trees — Chris Simpson, "Behavior trees for AI: How they work": https://www.gamedeveloper.com/programming/behavior-trees-for-ai-how-they-work ; behaviortree.js: https://github.com/Calamari/BehaviorTree.js +- Game Programming Patterns — State (FSM): https://gameprogrammingpatterns.com/state.html +- Recast/Detour navmesh: https://github.com/recastnavigation/recastnavigation ; recast-navigation-js: https://github.com/isaac-mason/recast-navigation-js ; three-pathfinding: https://github.com/donmccurdy/three-pathfinding +- GOAP (Jeff Orkin, F.E.A.R.): https://alumni.media.mit.edu/~jorkin/goap.html diff --git a/.grok/skills/building-games/references/audio.md b/.grok/skills/building-games/references/audio.md new file mode 100644 index 0000000..ca86a7f --- /dev/null +++ b/.grok/skills/building-games/references/audio.md @@ -0,0 +1,129 @@ +# Game Audio in the Browser (Web Audio API, mobile unlock, Howler.js, gain buses, spatial, latency) + +Consolidated from MDN Web Audio docs and Howler.js docs (see Sources). Focus: what an AI builder needs so audio **actually plays on mobile, stays low-latency, and mixes cleanly** — the #1 audio bug in browser games is "no sound on iOS." + +--- + +## 1. The autoplay unlock — the single most important rule + +Browsers block audio until the user interacts with the page. An `AudioContext` created on page load starts in the **`"suspended"`** state and will silently play nothing until resumed. + +**Rule: create/resume the AudioContext (or unlock your audio lib) from inside the FIRST real user gesture** (`click`/`touchend`/`keydown`), and call `resume()` **synchronously** in that handler's call stack (iOS Safari is strict — an `await` before `resume()` can break the gesture chain). + +```js +let audioCtx; +function unlockAudio() { + if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + if (audioCtx.state === 'suspended') audioCtx.resume(); // call synchronously in the gesture +} +// Attach once; remove after first success. `pointerdown`/`touchend`/`keydown` all count. +window.addEventListener('pointerdown', unlockAudio, { once: true }); +window.addEventListener('keydown', unlockAudio, { once: true }); +``` + +- Show a "tap to start" / "click to play" screen so the first gesture reliably unlocks audio (and any pointer lock / fullscreen you need). Games should not expect audio before the player clicks in. +- Also **resume on `visibilitychange`/focus** — contexts can get re-suspended when a tab is backgrounded on mobile. +- Listen to the `statechange` event if you need to reflect mute/paused UI. + +--- + +## 2. Web Audio API vs `