Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ The local Supabase stack still uses shared default ports and container names. In
- `src/` is the React 18 and TypeScript client. Routes and lazy page entry points live in `src/App.tsx`.
- `src/lib/stores/` owns browser state and Supabase hydration. `src/lib/ai/client.ts` is the client AI boundary.
- `packages/core/` (`@debatelab/core`, npm workspace) holds platform-neutral shared code: Zod schemas, validation, languages, Crossfire and Sparring logic. No DOM globals, React, or `import.meta` there; old `src/lib/` paths re-export it. Stores and i18n catalogs stay in `src/`.
- `apps/mobile/` (`@debatelab/mobile`, npm workspace) is the Expo iOS app (React 19, Expo Router). It imports `@debatelab/core`, the `src/locales` catalogs and `src/lib/supabase/projects.ts`; milestone 1 runs in Expo Go, so only Expo Go's native modules. Read [apps/mobile/AGENTS.md](apps/mobile/AGENTS.md) before changing it.
- `apps/mobile/` (`@debatelab/mobile`, npm workspace) is the Expo iOS app (React 19, Expo Router). It imports `@debatelab/core`, the `src/locales` catalogs and `src/lib/supabase/projects.ts`; it runs in Expo Go, and native modules Expo Go lacks go only into the development build (`DEBATELAB_BUILD=dev` in `app.config.ts`). Read [apps/mobile/AGENTS.md](apps/mobile/AGENTS.md) before changing it.
- `supabase/functions/` contains Deno Edge Functions. Shared auth, CORS, research, prompts, cost, and validation code lives in `supabase/functions/_shared/`.
- `supabase/migrations/` is the database history and schema source of truth.
- `src/lib/stores/debate-store.ts` owns the Current Debate: every source and saved result belongs to one debate or to one-off work, and nothing crosses debates.
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,7 @@ example
# generated native folders
/ios
/android

# EAS local credentials, written on build day and deleted after
credentials.json
credentials/
9 changes: 5 additions & 4 deletions apps/mobile/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,23 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp

## Rules

- Milestone 1 runs in the App Store's Expo Go, with no EAS builds. Every native module must be listed in `node_modules/expo/bundledNativeModules.json`. Check before adding a dependency, and install with `npx expo install <package>` from this folder so versions match the SDK.
- The app must keep running in the App Store's Expo Go. Install with `npx expo install <package>` from this folder so versions match the SDK. A native module that is not in `node_modules/expo/bundledNativeModules.json` belongs to the development build only: add its config plugin under `DEBATELAB_BUILD=dev` in `app.config.ts`, check `nativeCapabilities()` (`src/lib/native-capabilities.ts`) before using it, and never import it at the top level of a file Expo Go loads.
- A new native module, permission, entitlement or extension means a new cloud build, which has a strict budget (at most one a day and three a week without asking Adam). Batch native changes and read [docs/development.md](../../docs/development.md#development-build) before asking for one. Never run `eas build` or another EAS command that needs the account outside that runbook.
- Expo changes between SDKs. Read the docs for SDK 57 (`https://docs.expo.dev/versions/v57.0.0/`), not `latest`.
- React 18 (website) and React 19 (app) coexist in one workspace. Do not add a root `overrides` entry for react.
- Shared logic comes from `@debatelab/core` (`packages/core`). Stores, the AI client and other `src/` wiring stay in the website.
- Strings come from the website's catalogs in `src/locales/{en,es,hu}.json`. App-only strings go under `mobile.*`, in all three languages.
- The Supabase project URLs and anon keys live once, in `src/lib/supabase/projects.ts` at the repo root; `src/lib/supabase-config.ts` imports them. The app uses staging unless a build sets `EXPO_PUBLIC_SUPABASE_ENV=production`.
- Google sign-in needs a development build, because Expo Go cannot receive OAuth redirects. Until then, sign-in is email code or demo code.
- Native code has no DOM. eslint rejects `window`, `document`, `localStorage`, `sessionStorage` and `location` outside `*.web.ts(x)` files. A `.web.tsx` file exists only for the web preview.
- `ios/` and `android/` are generated and gitignored. Configure native behaviour in `app.json`.
- `ios/` and `android/` are generated and gitignored. Configure native behaviour in `app.config.ts`; without `DEBATELAB_BUILD` it must stay exactly the Expo Go config (compare `npx expo config --json` before and after). `eas.json` holds the one `development` profile; signing comes from a gitignored `credentials.json` written on build day.
- A form sheet's root view is `SheetScrollView` (`src/components/sheet.tsx`), with nothing around it: no `KeyboardAvoidingView`, no wrapping `View`. react-native-screens positions only a scroll view that is the sheet's direct child; a nested one lands below the visible sheet and the sheet looks empty.

## Layout

- `src/app/`: routes. `sign-in.tsx`; `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You; the form sheets `debates.tsx` (switcher), `new-debate.tsx`, `delete-account.tsx`, `add-link.tsx`, `new-note.tsx` and `source/[id].tsx`; and `ask.tsx`, `generate.tsx`, `case/[id].tsx`, `attack/`, `brainstorm/`, `factoids/` and `sparring/` (each a form `index.tsx` and a document `[id].tsx`), `crossfire.tsx` and `timer.tsx` (a full-screen modal), and `flowchart/` (the screen `index.tsx` with its `card`, `claim` and `maps` form sheets), full screen above the tabs. All are declared in the root `_layout.tsx`. Every Practice tool is in the app, so `(practice)/soon/[tool].tsx` is no longer reached from the Practice tab.
- `src/components/`: shared UI. `app-tabs.tsx` uses native tabs; `app-tabs.web.tsx` draws the mock's tab bar for the web preview.
- `src/lib/`: auth (`auth.tsx`), the Supabase client, session storage (AES key in SecureStore, ciphertext in AsyncStorage) and i18n. `debates.ts`, `settings.tsx`, `subscription.ts` and `account.ts` read and write the website's tables the way its stores do (`src/lib/stores/debate-store.ts`, `settings-store.ts`, `src/hooks/use-subscription.ts`, `src/lib/account.ts`), so the app and the website share one Current Debate and one set of settings. Change them together with the website code they mirror.
- `src/lib/`: auth (`auth.tsx`), `native-capabilities.ts` (what the running binary has beyond Expo Go), the Supabase client, session storage (AES key in SecureStore, ciphertext in AsyncStorage) and i18n. `debates.ts`, `settings.tsx`, `subscription.ts` and `account.ts` read and write the website's tables the way its stores do (`src/lib/stores/debate-store.ts`, `settings-store.ts`, `src/hooks/use-subscription.ts`, `src/lib/account.ts`), so the app and the website share one Current Debate and one set of settings. Change them together with the website code they mirror.
- `sources.ts` mirrors the session store's `addSource` and `removeSource` and `src/lib/source-import.ts`. The Library always shows the Current Debate, also in one-off mode, as on the website. Adding a file (the website parses files in the browser) and Scan pages are not in the app yet.
- Ask mirrors `src/lib/ai/streaming.ts` and `askChat` in `src/lib/ai/client.ts`: `ask.ts` sends the requests (plain questions stream from `ai-stream` through `expo/fetch`; with Citations, Web search, Pros/Cons or Argument map on, the whole answer comes from `ai`, because `ai-stream` refuses those options), `ai-request.ts` holds the shared POST with the website's headers, timeout and error kinds, `feature-config.ts` reads each tool's model from `user_settings.feature_configs`, `context-request.ts` builds the Current Debate context with the website's default selection and pins, and `chats.ts` saves finished exchanges to `chats` and `messages`. The SSE parser is shared in `packages/core/src/ai/sse.ts`. `markdown.ts` reads answers, because react-markdown needs the DOM.
- Generate and Attack mirror `generateCase` and `attackCase` in `src/lib/ai/client.ts` and the session store's `recordResult`: `results.ts` sends the requests through `ai-request.ts`, checks the output with the website's transform (moved to `packages/core/src/ai/generate-output.ts`), keeps researched sources in the debate's Auto-saved folder (`packages/core/src/ai/discovered-sources.ts`), saves the server's source rankings onto the Library sources a result used (`packages/core/src/ai/source-assessments.ts`, also for Ask's whole answers), says when the server shortened the input, and saves rows to `cases` and `attacks`. `motion.ts` and `motion-gate.ts` are the website's motion gate. `open-results.ts` holds results made in this session, so one-off work and failed saves still open as documents. It and the last Generate and Attack forms live in `session-memory.ts`, which the auth provider clears on every account change, including sign-out. `components/document.tsx` draws results as documents (mock 03); Brainstorm and Factoids reuse it. Export renders `export-html.ts` to a PDF with expo-print and shares it (`export.ts`); the web preview cannot print, so `export.web.ts` keeps the HTML and copied text on `window` for the screenshot script. Word export and Google Docs are not in the app.
Expand All @@ -44,4 +45,4 @@ PORT=<free port> npx tsx apps/mobile/scripts/screenshots.ts

Screenshots go to `.verify/mobile-shots/`. For signed-in screens, see the header of `scripts/screenshots.ts`. To load the app on a phone, see [docs/development.md](../../docs/development.md#mobile-app).

knip ignores `expo-updates`: its Expo plugin expects the package whenever `app.json` does not disable updates, and EAS Update is not set up yet.
knip ignores `expo-updates`: its Expo plugin expects the package whenever the app config does not disable updates, and EAS Update is not set up yet. It also ignores `expo-speech-recognition` and `expo-widgets`, which the app reaches by native module name, not by import.
131 changes: 131 additions & 0 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';

import type { ConfigContext, ExpoConfig } from 'expo/config';
import { type ConfigPlugin, withEntitlementsPlist } from 'expo/config-plugins';

// One config for Expo Go, the web export and the development build.
// DEBATELAB_BUILD=dev (set by the `development` profile in eas.json) adds the
// native modules Expo Go lacks. Without it the config is the one Expo Go has
// always used. DEBATELAB_APP_GROUP adds the lock-screen timer's widget
// extension, which needs an App Group: set it only when the provisioning
// profile carries that group. See docs/development.md#development-build.

type PermissionStrings = { speechRecognition: string; microphone: string };

const build = process.env.DEBATELAB_BUILD;
const appGroup = process.env.DEBATELAB_APP_GROUP;
if (build !== undefined && build !== '' && build !== 'dev') {
throw new Error(`DEBATELAB_BUILD must be "dev" or unset, not "${build}".`);
}
if (appGroup && build !== 'dev') {
throw new Error('DEBATELAB_APP_GROUP only applies to a development build (DEBATELAB_BUILD=dev).');
}

const bundleIdentifier = 'com.adam.debatelab';

function permissionStrings(projectRoot: string, language: 'en' | 'es' | 'hu'): PermissionStrings {
const file = path.join(projectRoot, '../../src/locales', `${language}.json`);
return JSON.parse(readFileSync(file, 'utf8')).mobile.permissions;
}

// Prebuild writes these into InfoPlist.strings as `KEY = "value";` without escaping.
const stringsValue = (value: string) => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');

function iosLocale(strings: PermissionStrings) {
return {
ios: {
NSSpeechRecognitionUsageDescription: stringsValue(strings.speechRecognition),
NSMicrophoneUsageDescription: stringsValue(strings.microphone),
},
};
}

// expo-widgets adds the push entitlement even with its push option off, and
// prebuild applies expo-notifications' plugin on its own once the package is
// installed, which adds it too. The signing profile has no push, so every
// development build drops it until a profile with push exists.
const withoutPushEntitlement: ConfigPlugin = (config) =>
withEntitlementsPlist(config, (mod) => {
delete mod.modResults['aps-environment'];
return mod;
});

export default ({ projectRoot }: ConfigContext): ExpoConfig => {
const config: ExpoConfig = {
name: 'DebateLab',
slug: 'debatelab',
version: '1.0.0',
orientation: 'default',
icon: './assets/images/icon.png',
scheme: 'debatelab',
userInterfaceStyle: 'automatic',
ios: {
bundleIdentifier,
supportsTablet: true,
infoPlist: {
CADisableMinimumFrameDurationOnPhone: true,
},
},
android: {
adaptiveIcon: {
backgroundColor: '#E6F4FE',
foregroundImage: './assets/images/android-icon-foreground.png',
backgroundImage: './assets/images/android-icon-background.png',
monochromeImage: './assets/images/android-icon-monochrome.png',
},
predictiveBackGestureEnabled: false,
},
web: {
output: 'static',
favicon: './assets/images/favicon.png',
},
plugins: [
'expo-router',
[
'expo-splash-screen',
{
backgroundColor: '#eef4fa',
image: './assets/images/splash-icon.png',
imageWidth: 76,
dark: { backgroundColor: '#0f172a' },
},
],
],
experiments: {
typedRoutes: true,
reactCompiler: true,
},
};

if (build !== 'dev') return config;

const en = permissionStrings(projectRoot, 'en');
config.ios!.infoPlist!.CFBundleAllowMixedLocalizations = true;
// English is the base Info.plist (from the plugin below); these translate it.
config.locales = {
es: iosLocale(permissionStrings(projectRoot, 'es')),
hu: iosLocale(permissionStrings(projectRoot, 'hu')),
};
config.plugins!.push(
'expo-dev-client',
[
'expo-speech-recognition',
{ speechRecognitionPermission: en.speechRecognition, microphonePermission: en.microphone },
],
);
if (appGroup) {
config.plugins!.push([
'expo-widgets',
{
bundleIdentifier: `${bundleIdentifier}.ExpoWidgetsTarget`,
groupIdentifier: appGroup,
enablePushNotifications: false,
widgets: [],
},
]);
}
// Registered before Expo applies the plugins list. Mods registered later act
// first, so this one runs after the plugins have written their entitlements.
return withoutPushEntitlement(config);
};
47 changes: 0 additions & 47 deletions apps/mobile/app.json

This file was deleted.

17 changes: 17 additions & 0 deletions apps/mobile/eas.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"cli": {
"version": ">= 24.0.0",
"appVersionSource": "local"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"credentialsSource": "local",
"node": "22.23.2",
"env": {
"DEBATELAB_BUILD": "dev"
}
}
}
}
5 changes: 4 additions & 1 deletion apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"version": "1.0.0",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"start": "expo start --go",
"export:web": "expo export --platform web",
"typecheck": "tsc --noEmit && tsc --noEmit -p scripts"
},
Expand All @@ -21,6 +21,7 @@
"expo-clipboard": "~57.0.2",
"expo-constants": "~57.0.21",
"expo-crypto": "~57.0.3",
"expo-dev-client": "~57.0.20",
"expo-file-system": "~57.0.7",
"expo-font": "~57.0.4",
"expo-haptics": "~57.0.3",
Expand All @@ -32,10 +33,12 @@
"expo-router": "~57.0.25",
"expo-secure-store": "~57.0.4",
"expo-sharing": "~57.0.22",
"expo-speech-recognition": "^57.1.1",
"expo-splash-screen": "~57.0.9",
"expo-status-bar": "~57.0.1",
"expo-system-ui": "~57.0.4",
"expo-web-browser": "~57.0.3",
"expo-widgets": "~57.0.24",
"i18next": "^23.16.8",
"react": "19.2.3",
"react-dom": "19.2.3",
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/scripts/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@
"lib": ["DOM", "ESNext"],
"types": ["node"]
},
"include": ["*.ts"]
"include": ["*.ts", "../app.config.ts"]
}
51 changes: 51 additions & 0 deletions apps/mobile/src/lib/native-capabilities.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const env = vi.hoisted(() => ({
executionEnvironment: 'storeClient',
modules: {} as Record<string, object>,
}));
const requireOptionalNativeModule = vi.hoisted(() => vi.fn((name: string) => env.modules[name] ?? null));

vi.mock('expo', () => ({ requireOptionalNativeModule }));
vi.mock('expo-constants', () => ({
default: {
get executionEnvironment() {
return env.executionEnvironment;
},
},
ExecutionEnvironment: { Bare: 'bare', Standalone: 'standalone', StoreClient: 'storeClient' },
}));

import { nativeCapabilities } from './native-capabilities';

describe('nativeCapabilities', () => {
beforeEach(() => {
requireOptionalNativeModule.mockClear();
});

it('reports nothing in Expo Go and never looks a native module up', () => {
env.executionEnvironment = 'storeClient';
// Even if a lookup would succeed, Expo Go must not be asked.
env.modules = { ExpoSpeechRecognition: {}, ExpoWidgets: { widgetsDirectory: 'file:///group/ExpoWidgets/' } };
expect(nativeCapabilities()).toEqual({ speechRecognition: false, liveActivity: false });
expect(requireOptionalNativeModule).not.toHaveBeenCalled();
});

it('reports speech but no Live Activity in a development build without an App Group', () => {
env.executionEnvironment = 'bare';
env.modules = { ExpoSpeechRecognition: {}, ExpoWidgets: { widgetsDirectory: null } };
expect(nativeCapabilities()).toEqual({ speechRecognition: true, liveActivity: false });
});

it('reports both when the build has the speech module and the App Group container', () => {
env.executionEnvironment = 'bare';
env.modules = { ExpoSpeechRecognition: {}, ExpoWidgets: { widgetsDirectory: 'file:///group/ExpoWidgets/' } };
expect(nativeCapabilities()).toEqual({ speechRecognition: true, liveActivity: true });
});

it('reports nothing where the modules are missing, as in the web preview', () => {
env.executionEnvironment = 'bare';
env.modules = {};
expect(nativeCapabilities()).toEqual({ speechRecognition: false, liveActivity: false });
});
});
Loading
Loading