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
6 changes: 4 additions & 2 deletions apps/mobile/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,17 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp

## 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/` and `factoids/` (each a form `index.tsx` and a document `[id].tsx`), 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`. `(practice)/soon/[tool].tsx` stands in for Practice tools not in the app yet.
- `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/` and `factoids/` (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`. `(practice)/soon/[tool].tsx` stands in for Practice tools not in the app yet.
- `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.
- `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.
- Brainstorm and Factoids mirror `brainstorm` and `factoids` in `src/lib/ai/client.ts` and their pages, through the same `results.ts`: the brainstorm transform and the verified-factoid selection live in `packages/core/src/ai/brainstorm-output.ts` and `factoids-output.ts`, rows go to `brainstorms` and `factoids`. Brainstorm has the motion gate; Factoids, like Attack, follows the Current Debate and saves with side `neutral`. A factoid's bookmark saves it to the Current Debate's `Factoids` folder, and a search suggestion reopens the Factoids form with that query. `prepareToolHref` in `components/prepare-tools.ts` is where both tabs open a tool.
- Timer mirrors `src/pages/TimerPage.tsx`: `timer.ts` holds the formats and a clock made of start timestamps, never counted ticks, so it is right after the app returns from the background (`timer.test.ts`). `timer-signals.ts` plays the bundled beeps in `assets/sounds` (made by `scripts/render-timer-sounds.ts`) with expo-audio, taps with expo-haptics and keeps the screen awake while a speech runs; `timer-signals.web.ts` only records the signals for the screenshot script. Lock-screen timers wait for the development build (`TODO(live-activity)`).
- Crossfire is the website's typed mode: `crossfire.ts` sends `crossfire` and its signal requests as `src/lib/ai/client.ts` does (Ask's model settings, 75 s and 5 s deadlines) and saves a finished round to `crossfires` in its debate, as CrossfirePage's `recordResult` call; the reducer and schemas are `packages/core/src/crossfire`. The unfinished round is a session slot, so leaving the screen keeps it. Voice rounds need the development build, so the voice switch shows disabled. The website cannot reopen a saved round, so neither can the app.
- Flowchart mirrors the website's `FlowchartPage.tsx` and the session store's `saveFlowchart` and `deleteSavedFlowchart` without React Flow: the saved `flowcharts` row keeps the website's React Flow node and edge JSON, and `packages/core/src/flowchart.ts` reads and edits it (starter map, edge kinds from the label or stroke the website writes, role columns, the tree, and edits that keep every field they do not touch). `flowcharts.ts` reads and writes the rows; Save over an opened map updates it, Save as inserts a row as the website's Save does. `flowchart-drafts.ts` holds each debate's open map and unsaved edits in session memory, shared by the screen and its sheets. Maps follow `activeDebateId`: one-off maps are saved with no debate, as on the website. The website's PNG export is a PDF of the Map tree here (`flowchartExportHtml`).
- One-off mode is session-only and never written, as on the website; only maps saved in it are kept, with no debate, as the website keeps them. The switcher turns it on with or without a Current Debate, and `OneOffNotice` shows it on the Debate tab, Prepare, Ask, Library and the Generate, Attack, Brainstorm and Factoids forms (through `WorkingIn`) and the Flowchart. The Debate tab, Prepare and Library still list the Current Debate's work in one-off mode, as the website's home does. Students prepare both sides of a motion, so a debate has no side: the Debate tab's In favor / Against toggle only chooses which side's work it shows, using the website's rule (`'both'` counts for either side, neutral and side-less work for neither). It starts on In favor and is never stored.
- One-off mode is session-only and never written, as on the website; only maps saved in it are kept, with no debate, as the website keeps them. The switcher turns it on with or without a Current Debate, and `OneOffNotice` shows it on the Debate tab, Prepare, Ask, Library and the Generate, Attack, Brainstorm, Factoids and Crossfire forms (through `WorkingIn`) and the Flowchart. The Debate tab, Prepare and Library still list the Current Debate's work in one-off mode, as the website's home does. Students prepare both sides of a motion, so a debate has no side: the Debate tab's In favor / Against toggle only chooses which side's work it shows, using the website's rule (`'both'` counts for either side, neutral and side-less work for neither). It starts on In favor and is never stored.
- `src/theme/`: colours from the mocks and the website's dark palette, Inter fonts.
- `scripts/screenshots.ts`: web-export screenshots. `scripts/render-tab-icons.ts` redraws the tab bar PNGs.

Expand Down
7 changes: 7 additions & 0 deletions apps/mobile/assets/sounds/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Timer sounds

`beep.wav` (one minute left) and `double-beep.wav` (time up) are generated by
[scripts/render-timer-sounds.ts](../../scripts/render-timer-sounds.ts), which
copies the website's Web Audio synth in `src/pages/TimerPage.tsx`: an 880 Hz
sine at 22.05 kHz, 16-bit mono. Rerun the script to change them; don't edit
the files by hand.
Binary file added apps/mobile/assets/sounds/beep.wav
Binary file not shown.
Binary file added apps/mobile/assets/sounds/double-beep.wav
Binary file not shown.
3 changes: 3 additions & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,14 @@
"aes-js": "^3.1.2",
"date-fns": "^3.6.0",
"expo": "~57.0.27",
"expo-audio": "~57.0.5",
"expo-clipboard": "~57.0.2",
"expo-constants": "~57.0.21",
"expo-crypto": "~57.0.3",
"expo-file-system": "~57.0.7",
"expo-font": "~57.0.4",
"expo-haptics": "~57.0.3",
"expo-keep-awake": "~57.0.2",
"expo-linear-gradient": "~57.0.2",
"expo-linking": "~57.0.12",
"expo-localization": "~57.0.2",
Expand Down
68 changes: 68 additions & 0 deletions apps/mobile/scripts/render-timer-sounds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Writes the Timer's signal sounds to assets/sounds as 16-bit mono WAV files.
* They copy the website's Web Audio synth (playSignal in src/pages/TimerPage.tsx):
* an 880 Hz sine, one beep fading out over 0.5 s, and two beeps for time up
* (0.1 s on, a 50 ms gap, then a fade to 0.6 s). The website plays them at a
* tenth of full scale; a phone speaker needs more, so these peak at half.
* Run from the repository root after changing a sound:
*
* npx tsx apps/mobile/scripts/render-timer-sounds.ts
*/
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';

const RATE = 22_050;
const FREQUENCY = 880;
const PEAK = 0.5;
// A few milliseconds of ramp at each edge, so the tone starts and stops without a click.
const EDGE = 0.004;
const outDir = join(__dirname, '../assets/sounds');

/** Gain at time t for a tone that starts at `from`, holds, then fades exponentially to 0.001 by `to`. */
function envelope(t: number, from: number, to: number, holdUntil = from): number {
if (t < from || t > to) return 0;
const attack = Math.min(1, (t - from) / EDGE);
const release = Math.min(1, (to - t) / EDGE);
const fade = t <= holdUntil ? 1 : Math.pow(0.001 / PEAK, (t - holdUntil) / (to - holdUntil));
return attack * release * fade;
}

function render(seconds: number, gain: (t: number) => number): Buffer {
const samples = Math.round(seconds * RATE);
const data = Buffer.alloc(samples * 2);
for (let i = 0; i < samples; i += 1) {
const t = i / RATE;
const value = Math.sin(2 * Math.PI * FREQUENCY * t) * PEAK * gain(t);
data.writeInt16LE(Math.round(Math.max(-1, Math.min(1, value)) * 32_767), i * 2);
}
const header = Buffer.alloc(44);
header.write('RIFF', 0);
header.writeUInt32LE(36 + data.length, 4);
header.write('WAVE', 8);
header.write('fmt ', 12);
header.writeUInt32LE(16, 16);
header.writeUInt16LE(1, 20);
header.writeUInt16LE(1, 22);
header.writeUInt32LE(RATE, 24);
header.writeUInt32LE(RATE * 2, 28);
header.writeUInt16LE(2, 32);
header.writeUInt16LE(16, 34);
header.write('data', 36);
header.writeUInt32LE(data.length, 40);
return Buffer.concat([header, data]);
}

async function main() {
await mkdir(outDir, { recursive: true });
await writeFile(join(outDir, 'beep.wav'), render(0.5, (t) => envelope(t, 0, 0.5)));
await writeFile(
join(outDir, 'double-beep.wav'),
render(0.6, (t) => (t < 0.1 ? envelope(t, 0, 0.1, 0.1) : envelope(t, 0.15, 0.6))),
);
console.log(`Wrote beep.wav and double-beep.wav to ${outDir}`);
}

main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
Loading
Loading