<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Naima Kader</title>
    <description>The latest articles on DEV Community by Naima Kader (@naima_kader_75d582f85fcf9).</description>
    <link>https://dev.to/naima_kader_75d582f85fcf9</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2443725%2F984e62aa-8798-4a30-99aa-05bdec050ee0.png</url>
      <title>DEV Community: Naima Kader</title>
      <link>https://dev.to/naima_kader_75d582f85fcf9</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/naima_kader_75d582f85fcf9"/>
    <language>en</language>
    <item>
      <title>How I Built Phantom: A Chrome Extension That Fixes Accessibility So Developers Don't Have To</title>
      <dc:creator>Naima Kader</dc:creator>
      <pubDate>Sat, 08 Aug 2026 13:32:39 +0000</pubDate>
      <link>https://dev.to/naima_kader_75d582f85fcf9/how-i-built-phantom-a-chrome-extension-that-fixes-accessibility-so-developers-dont-have-to-5036</link>
      <guid>https://dev.to/naima_kader_75d582f85fcf9/how-i-built-phantom-a-chrome-extension-that-fixes-accessibility-so-developers-dont-have-to-5036</guid>
      <description>&lt;p&gt;1.3 billion people worldwide live with some form of disability. When they visit a website, there is a 97% chance it will fail basic accessibility standards — broken form labels, missing image descriptions, unreadable color contrast, keyboard traps that make navigation impossible.&lt;/p&gt;

&lt;p&gt;Every tool that exists to help — Lighthouse, axe DevTools, WAVE — does the same thing. It generates a report. It tells you what is broken. Then it leaves you alone.&lt;/p&gt;

&lt;p&gt;A developer reads the report, tries to find the broken element somewhere in thousands of lines of HTML, researches what the WCAG rule actually means, attempts a fix, and re-runs the audit. This process takes hours. Most companies never get around to it.&lt;/p&gt;

&lt;p&gt;I wanted to build something different. Not a report — a workspace.&lt;/p&gt;

&lt;p&gt;That became Phantom.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Phantom is a Chrome Extension that transforms how developers interact with accessibility issues. It finds every broken element. It draws a border around it on the real page. It shows you the broken HTML. It generates the corrected HTML. It exports a professional PDF report in one click.&lt;/p&gt;

&lt;p&gt;GitHub: github.com/naimakader/phantom&lt;br&gt;
Stack: React 18, TypeScript, Chrome Extension Manifest V3, axe-core, jsPDF, Vite&lt;/p&gt;




&lt;h2&gt;
  
  
  The Hardest Technical Problem: Scanning a Live Page From Inside a Popup
&lt;/h2&gt;

&lt;p&gt;A Chrome Extension popup is an isolated HTML page. It cannot directly read or modify the tab the user is browsing. The only bridge is Chrome's scripting API.&lt;/p&gt;

&lt;p&gt;The naive approach — injecting axe-core as a script tag — fails in Manifest V3 because of the new Content Security Policy restrictions. I discovered the solution after reading Chrome Extension source code and Manifest V3 migration guides: a two-step injection pattern.&lt;/p&gt;

&lt;p&gt;Step 1: chrome.scripting.executeScript({ files: ['axe.min.js'] })&lt;br&gt;
This injects axe-core as a web-accessible resource into the live page.&lt;/p&gt;

&lt;p&gt;Step 2: chrome.scripting.executeScript({ func: runAxeScan })&lt;br&gt;
Now that axe exists on the page, this runs axe.run() and returns the results.&lt;/p&gt;

&lt;p&gt;This two-call pattern is not documented anywhere. It is the only reliable way to run a WebAssembly-powered accessibility engine inside a foreign page from an extension popup.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Live Element Highlighter
&lt;/h2&gt;

&lt;p&gt;This was the hardest feature to build. After scanning, every broken element on the page should glow red — without breaking the page, without affecting the site's own CSS, and without persisting after the user clears highlights.&lt;/p&gt;

&lt;p&gt;Three problems had to be solved.&lt;/p&gt;

&lt;p&gt;Z-index conflicts. Many sites use z-index: 9999 on overlays and modals. A naive outline injection would be hidden behind them. The solution was to use outline with outline-offset rather than border or box-shadow — outlines render outside the element's box model and are not affected by the stacking context of child elements.&lt;/p&gt;

&lt;p&gt;Cleaning up. Every injected style was scoped to a unique id (phantom-styles) so a single element.remove() call cleans everything up. No leftover CSS, no data attributes.&lt;/p&gt;

&lt;p&gt;Hover tooltips without a React tree. The tooltip had to work inside the live page, not inside the popup. This meant pure DOM manipulation — a CSS ::after pseudo-element reading from a data-phantom-label attribute set per element. No React, no event listeners, no memory leaks.&lt;/p&gt;




&lt;h2&gt;
  
  
  Extracting Live HTML for the Fix Engine
&lt;/h2&gt;

&lt;p&gt;The fix engine needed the actual broken HTML from the page — not a generic example. axe-core returns a CSS selector for each violation. Using that selector to extract the outerHTML required another chrome.scripting.executeScript call with the selector passed as an argument.&lt;/p&gt;

&lt;p&gt;The key constraint: chrome.scripting functions run in a completely isolated context. They cannot close over variables from the popup. Everything must be passed explicitly as args. This forced a clean separation between popup state and page-side execution that made the code more reliable overall.&lt;/p&gt;




&lt;h2&gt;
  
  
  Client-Side PDF Generation Without a Backend
&lt;/h2&gt;

&lt;p&gt;The PDF report needed to look professional — dark cover page, score ring, severity badges, paginated violation list, page numbers. jsPDF provides a low-level drawing API similar to Canvas — every element is positioned with explicit coordinates.&lt;/p&gt;

&lt;p&gt;The score ring required manual arc math:&lt;/p&gt;

&lt;p&gt;circumference = 2π × radius&lt;br&gt;
arc_length = circumference × (score / 100)&lt;br&gt;
strokeDashoffset = circumference - arc_length&lt;/p&gt;

&lt;p&gt;Page breaks required tracking the current Y position and triggering a new page when remaining space was insufficient for the next violation card.&lt;/p&gt;

&lt;p&gt;Zero backend infrastructure. Zero user data leaving the machine. Instant downloads.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why These Architecture Decisions
&lt;/h2&gt;

&lt;p&gt;axe-core over Lighthouse: Lighthouse requires a full page reload and runs in a separate DevTools process. It cannot scan a page being actively browsed. axe-core runs as a JavaScript library directly inside the page's DOM — it sees exactly what the user sees, including dynamically rendered content and SPA state.&lt;/p&gt;

&lt;p&gt;Manifest V3 over V2: Google deprecated Manifest V2 in 2024. V3's service worker model forced a cleaner architecture — instead of a persistent background page with direct DOM access, all page interaction is explicit and auditable through chrome.scripting.&lt;/p&gt;

&lt;p&gt;chrome.storage over localStorage: chrome.storage.local is shared across all extension contexts — popup, background worker, content scripts. It persists across extension updates and browser restarts. localStorage is scoped to a single page origin and is destroyed when the popup closes.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;The browser is a platform, not just a runtime. Building Phantom required understanding how Chrome manages isolated execution contexts, how the scripting API bridges them, and how CSS rendering works at the level of stacking contexts and box models. This is a different category of knowledge from building React applications.&lt;/p&gt;

&lt;p&gt;Constraints produce better architecture. The Manifest V3 restriction on background page access forced every page interaction through a single, explicit API. The resulting code is easier to reason about than the V2 equivalent would have been.&lt;/p&gt;

&lt;p&gt;The demo is the product. The most important engineering decision I made was choosing the live element highlighter as a feature. A popup showing a list of issues is forgettable. A tool that draws red borders on a real website while you watch — that is memorable. When I demo Phantom, people understand the problem and the solution in under 10 seconds.&lt;/p&gt;




&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;57 WCAG 2.2 rules checked per scan&lt;/li&gt;
&lt;li&gt;Scans BBC.com in under 3 seconds&lt;/li&gt;
&lt;li&gt;PDF report generates and downloads in under 1 second&lt;/li&gt;
&lt;li&gt;13 unit tests — 100% passing&lt;/li&gt;
&lt;li&gt;7 features shipped&lt;/li&gt;
&lt;li&gt;Zero backend infrastructure&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;Multi-page site audit — crawl an entire domain and aggregate scores across pages.&lt;br&gt;
Score trend chart — visualize accessibility improvement over time using D3.&lt;br&gt;
Real AI integration — replace smart mock fixes with actual AI-generated fixes using the Anthropic API.&lt;/p&gt;




&lt;h2&gt;
  
  
  Final Thought
&lt;/h2&gt;

&lt;p&gt;Building Phantom taught me that the browser is one of the most underestimated platforms in software development. Most developers use it as a display layer. It is actually a full operating system with security boundaries, execution contexts, storage APIs, and scripting capabilities that most people never touch.&lt;/p&gt;

&lt;p&gt;I touched all of it. And the result is a tool that makes the web slightly more accessible for 1.3 billion people.&lt;/p&gt;

&lt;p&gt;That felt worth building.&lt;/p&gt;




&lt;p&gt;Built by Naima Kader&lt;br&gt;
Portfolio: &lt;a href="https://portfolio-seven-beryl-29.vercel.app/" rel="noopener noreferrer"&gt;https://portfolio-seven-beryl-29.vercel.app/&lt;/a&gt;&lt;br&gt;
GitHub: github.com/naimakader/phantom&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>webdev</category>
      <category>a11y</category>
    </item>
    <item>
      <title>How I Built NeuroSpace: An AI Productivity Platform That Actually Thinks With You</title>
      <dc:creator>Naima Kader</dc:creator>
      <pubDate>Sat, 08 Aug 2026 04:32:22 +0000</pubDate>
      <link>https://dev.to/naima_kader_75d582f85fcf9/how-i-built-neurospace-an-ai-productivity-platform-that-actually-thinks-with-you-4f1b</link>
      <guid>https://dev.to/naima_kader_75d582f85fcf9/how-i-built-neurospace-an-ai-productivity-platform-that-actually-thinks-with-you-4f1b</guid>
      <description>&lt;p&gt;Most productivity apps are passive. They hold your tasks and wait. You still have to decide what to work on, estimate how long things take, and figure out why you keep missing deadlines. The cognitive overhead of managing a productivity system often exceeds the benefit.&lt;/p&gt;

&lt;p&gt;I wanted to answer one question: what would a productivity tool look like if it actually thought alongside you?&lt;/p&gt;

&lt;p&gt;That question became NeuroSpace.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;NeuroSpace is a full-stack AI productivity platform. The core loop is simple: plan your work with AI, execute it with a focus timer, review your patterns with data analysis, and get warned before you fall behind.&lt;/p&gt;

&lt;p&gt;Live: neurospace-zr2n.vercel.app&lt;br&gt;
Code: github.com/naimakader/Neurospace&lt;/p&gt;




&lt;h2&gt;
  
  
  The Stack and Why
&lt;/h2&gt;

&lt;p&gt;I chose Next.js 15 App Router because server components reduce the client bundle and API routes keep server logic close to the UI that uses it.&lt;/p&gt;

&lt;p&gt;TypeScript gave me shared types between API responses and UI state — the source of truth lives in one place instead of being duplicated.&lt;/p&gt;

&lt;p&gt;For auth I used Clerk. Production-grade authentication without building session management from scratch. JWT templates let Clerk tokens authenticate Supabase requests directly.&lt;/p&gt;

&lt;p&gt;For the database I chose Supabase with PostgreSQL. Row-level security scopes every query to the authenticated user automatically. A JSONB column stores session snapshots efficiently — more on why that matters below.&lt;/p&gt;

&lt;p&gt;For AI I used OpenAI GPT-4o-mini. Best price-to-quality ratio for structured JSON generation and conversational responses.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Hardest Engineering Decision: Session Snapshots
&lt;/h2&gt;

&lt;p&gt;The naive approach to loading state: on every page load, fetch all tasks and reconstruct the board. The problem is that undo/redo history is lost, column order is lost, and archived tasks disappear.&lt;/p&gt;

&lt;p&gt;My approach: after every mutation, save the complete state as { tasks, archived } to a task_history table using an upsert on user_id. One row per user, always current.&lt;/p&gt;

&lt;p&gt;On load, I try the snapshot first. It is the complete, ordered, correct state. I fall back to the tasks table only if no snapshot exists.&lt;/p&gt;

&lt;p&gt;This also makes undo/redo persistent across page refreshes. After undo, the restored state saves immediately as the new snapshot and re-inserts into the tasks table so the database matches memory.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why useRef for Undo/Redo
&lt;/h2&gt;

&lt;p&gt;Undo/redo stacks do not cause re-renders — they only matter at the moment of undo. Using useState would trigger unnecessary renders on every single mutation registration.&lt;/p&gt;

&lt;p&gt;I used useRef for both the past and future stacks, and structuredClone to create deep copies that handle Date objects correctly — safer than JSON.parse/stringify.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Midnight Archiving Instead of 24 Hours
&lt;/h2&gt;

&lt;p&gt;A task completed at 11pm would still appear in the Done column the next morning under a 24-hour rule. The correct boundary is midnight in local time — not UTC, which would shift by the user's timezone offset.&lt;/p&gt;

&lt;p&gt;This is a small detail that completely changes the user experience. Getting it wrong makes the app feel broken even when the code is technically correct.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Bugs That Taught Me the Most
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Silent persistence failure.&lt;/strong&gt; Tasks were saving locally and appearing on the board but disappearing on refresh. No errors in the console. Every POST request returned 400 silently. The API was validating the wrong data shape — the snapshot structure had changed when I added archive support but the API never got updated. Lesson: when two systems share a data contract, define the type once and import it in both places.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Undo across refresh.&lt;/strong&gt; Undo worked perfectly in the same session. After refresh the undone delete came back. The task had been physically deleted from Supabase — the snapshot saved the restored state but the tasks table did not have the row. Fix: after every undo/redo, sync the actual database state to match restored state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hydration mismatch.&lt;/strong&gt; Math.random() was called at module level to generate a tab identifier. Server rendered one value, client hydrated with a different value. React threw a hydration error. Fix: move random generation inside a useRef that only initializes on the client.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Case sensitivity on Linux.&lt;/strong&gt; The app worked perfectly on Windows locally. Vercel build failed with Module not found. The file was saved as focusMode.tsx — Windows is case-insensitive, Linux is not. Fix: git mv to rename the file. Lesson: always match import casing to file casing exactly.&lt;/p&gt;




&lt;h2&gt;
  
  
  AI Integration: Graceful Degradation
&lt;/h2&gt;

&lt;p&gt;Every AI endpoint has a local fallback. The app works without OpenAI credits. It gets smarter when you have them.&lt;/p&gt;

&lt;p&gt;The planner also adapts to the user's energy level before calling GPT. A mood multiplier adjusts session lengths — low energy gets 60% of normal session length, high energy gets 130%. GPT's time allocations match actual capacity instead of assuming everyone works the same way.&lt;/p&gt;




&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Clerk JWT to Supabase auth: under 100ms per request&lt;/li&gt;
&lt;li&gt;Full state restoration: single JSONB query&lt;/li&gt;
&lt;li&gt;AI plan generation: 1-3 seconds, instant with local fallback&lt;/li&gt;
&lt;li&gt;Undo/redo: O(1) push/pop with ref stacks, persistent across refresh&lt;/li&gt;
&lt;li&gt;First load JS: 287kb&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What I Would Do Differently
&lt;/h2&gt;

&lt;p&gt;Define API contracts first. I built the provider and API routes in parallel and they drifted. In a real team you write the OpenAPI spec first and generate types from it.&lt;/p&gt;

&lt;p&gt;Test the error paths, not just the happy path. The silent persistence failure would have been caught immediately by a test asserting the snapshot shape.&lt;/p&gt;

&lt;p&gt;Use a type-safe API client. Raw fetch with manual JSON parsing requires manual type assertions everywhere. tRPC would have made the shape mismatch impossible.&lt;/p&gt;

&lt;p&gt;Never assume case-insensitivity. Build and test on Linux from day one.&lt;/p&gt;




&lt;h2&gt;
  
  
  Final Thought
&lt;/h2&gt;

&lt;p&gt;The hardest part of building something complex is not any single technical problem. It is keeping the whole system coherent while solving them one by one.&lt;/p&gt;

&lt;p&gt;NeuroSpace taught me that more than any tutorial ever could.&lt;/p&gt;




&lt;p&gt;Built by Naima Kader&lt;br&gt;
Portfolio: &lt;a href="https://portfolio-seven-beryl-29.vercel.app/" rel="noopener noreferrer"&gt;https://portfolio-seven-beryl-29.vercel.app/&lt;/a&gt;&lt;br&gt;
GitHub: github.com/naimakader/Neurospace&lt;br&gt;
Live: neurospace-zr2n.vercel.app&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>typescript</category>
      <category>supabase</category>
      <category>openai</category>
    </item>
    <item>
      <title>I Built an AI Courtroom Simulator with Next.js and OpenAI — Here's the Full Technical Breakdown</title>
      <dc:creator>Naima Kader</dc:creator>
      <pubDate>Tue, 04 Aug 2026 05:13:56 +0000</pubDate>
      <link>https://dev.to/naima_kader_75d582f85fcf9/i-built-an-ai-courtroom-simulator-with-nextjs-and-openai-heres-the-full-technical-breakdown-54jd</link>
      <guid>https://dev.to/naima_kader_75d582f85fcf9/i-built-an-ai-courtroom-simulator-with-nextjs-and-openai-heres-the-full-technical-breakdown-54jd</guid>
      <description>&lt;h1&gt;
  
  
  I Built an AI Courtroom Simulator That Lets Law Students Practice Against a Judge, Prosecutor, and Witness — Here's How
&lt;/h1&gt;

&lt;p&gt;When I started building LexAI I had one question: what would happen if you put three AI personas in a courtroom and let a law student argue against all of them simultaneously?&lt;/p&gt;

&lt;p&gt;Six weeks later I had my answer — and a production app that law students are actually using.&lt;/p&gt;

&lt;p&gt;This is the full technical breakdown of how I built it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Moot court is how law students learn to argue. The problem is brutal:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A professional coach costs &lt;strong&gt;$500 per hour&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Most law schools offer fewer than 12 practice sessions per year&lt;/li&gt;
&lt;li&gt;Students who cannot afford coaching lose more cases&lt;/li&gt;
&lt;li&gt;There was no free, intelligent alternative&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I am a self-taught developer based in Jijiga, Ethiopia. I have never been to law school. But I recognized a product problem with a clear technical solution — and I built it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;LexAI is a full-stack AI courtroom simulator with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;3 AI personas&lt;/strong&gt; — a strict federal judge, an aggressive prosecutor, and a defensive witness&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-time argument scoring&lt;/strong&gt; — every argument rated 0 to 100 on logic, precedent, and persuasiveness&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Witness cross-examination&lt;/strong&gt; with contradiction detection&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session replay timeline&lt;/strong&gt; showing score progression across every turn&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-time multiplayer battle mode&lt;/strong&gt; — two students argue opposite sides simultaneously&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Professor dashboard&lt;/strong&gt; with class management and student analytics&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shareable session cards&lt;/strong&gt; with dynamic OG image generation on the edge&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;10 landmark cases&lt;/strong&gt; including Miranda, Brown v. Board, Roe v. Wade, Apple v. Samsung&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Live: &lt;a href="https://lexai-fd92.vercel.app" rel="noopener noreferrer"&gt;lexai-fd92.vercel.app&lt;/a&gt;&lt;br&gt;
GitHub: &lt;a href="https://github.com/naimakader/Lexai" rel="noopener noreferrer"&gt;github.com/naimakader/Lexai&lt;/a&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  The Stack
&lt;/h2&gt;

&lt;p&gt;Next.js 15 App Router&lt;br&gt;
TypeScript&lt;br&gt;
Tailwind CSS&lt;br&gt;
Supabase (PostgreSQL + Realtime)&lt;br&gt;
Clerk Authentication&lt;br&gt;
OpenAI GPT-4o-mini&lt;br&gt;
Vercel OG (Edge Runtime)&lt;br&gt;
Framer Motion&lt;/p&gt;


&lt;h2&gt;
  
  
  The Hardest Technical Problem — Multi-Persona AI State
&lt;/h2&gt;

&lt;p&gt;The core challenge was keeping three AI personas consistent across a long conversation.&lt;/p&gt;

&lt;p&gt;Each persona needed to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Respond in character without breaking tone&lt;/li&gt;
&lt;li&gt;Remember what was said earlier in the session&lt;/li&gt;
&lt;li&gt;React to the user's specific argument — not a generic response&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;My solution was to send the full conversation history to OpenAI on every request with a role-locked system prompt. Each API call includes the complete transcript so the AI has full context.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`
You are running a courtroom simulation.

Case facts: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;caseData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;facts&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;

Conversation so far:
&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;conversation&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;

Respond with a JSON object with exactly these 5 fields:
- judgeResponse: The judge's response (1-2 sentences, formal)
- prosecutionResponse: The prosecution's counter-argument (aggressive)
- score: 0 to 100 rating the defense's last argument
- scoreDelta: How much the score changed from previous turn
- feedback: One short coaching sentence for the defense

Return only valid JSON. No extra text.
`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using &lt;code&gt;response_format: { type: "json_object" }&lt;/code&gt; on GPT-4o-mini guarantees structured output every time. No parsing failures, no broken JSON.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Witness Contradiction Detection System
&lt;/h2&gt;

&lt;p&gt;This was the feature that surprised me most technically.&lt;/p&gt;

&lt;p&gt;The witness has a prepared testimony. When the user asks questions, the AI tries to stay consistent. But if the user asks a clever question that exposes an inconsistency — the witness stumbles.&lt;/p&gt;

&lt;p&gt;The key insight was in the system prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`
You are playing the role of a witness in a courtroom cross-examination.

Your original testimony: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;caseData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;witness&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;testimony&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;

IMPORTANT RULES:
- Stay consistent with your original testimony unless the attorney 
  asks a very clever question that exposes a contradiction
- If caught in a contradiction admit it reluctantly but try to explain it away
- Be evasive and defensive when pressed on weak points
- Never volunteer information the attorney did not ask for

Return a JSON object including:
- witnessResponse: Your answer (1-3 sentences)
- contradiction: true if the attorney caught a contradiction
- score: 0 to 100 rating the question's effectiveness
`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;contradiction: true&lt;/code&gt; comes back, a red banner flashes on screen and the score jumps. Users genuinely feel the moment they catch the witness.&lt;/p&gt;




&lt;h2&gt;
  
  
  Real-Time Multiplayer with Supabase Realtime
&lt;/h2&gt;

&lt;p&gt;The battle mode was the most technically interesting feature to build.&lt;/p&gt;

&lt;p&gt;Two players join the same room — one as defense, one as prosecution. Every argument one player makes triggers an AI judge response that both players see simultaneously.&lt;/p&gt;

&lt;p&gt;The architecture:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Player 1 creates a room — gets a 6-letter code&lt;/li&gt;
&lt;li&gt;Player 2 enters the code — joins as prosecution&lt;/li&gt;
&lt;li&gt;When either player submits an argument, the API route calls OpenAI, saves the updated messages to Supabase, and returns the response&lt;/li&gt;
&lt;li&gt;Supabase Realtime fires a &lt;code&gt;postgres_changes&lt;/code&gt; event to both clients&lt;/li&gt;
&lt;li&gt;Both UIs update simultaneously
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;channel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`battle_room_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;postgres_changes&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;UPDATE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;public&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;battle_rooms&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`id=eq.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;setRoom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The beauty of this approach is simplicity. I do not need WebSocket servers or complex state synchronization. Supabase handles everything. One database update triggers real-time UI updates across every connected client.&lt;/p&gt;




&lt;h2&gt;
  
  
  Dynamic OG Images on the Edge
&lt;/h2&gt;

&lt;p&gt;After finishing a session users can share their results on LinkedIn and Twitter. When they paste the link, a dynamic preview image appears showing their score, grade, case name, and best argument.&lt;/p&gt;

&lt;p&gt;This uses Vercel's &lt;code&gt;@vercel/og&lt;/code&gt; library running on the Edge Runtime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;runtime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;edge&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;searchParams&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;caseTitle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;case&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;State v. Miranda&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;score&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bestArgument&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;best&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ImageResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{{&lt;/span&gt; &lt;span class="na"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#03030A&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;100%&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;100%&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="c1"&gt;// JSX rendered to a 1200x630 PNG on the edge&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/div&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;,
&lt;/span&gt;    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;630&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every share generates a unique image in milliseconds. No pre-rendering, no storage costs.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Clerk + Supabase Auth Problem
&lt;/h2&gt;

&lt;p&gt;This was the bug that cost me the most time.&lt;/p&gt;

&lt;p&gt;Clerk handles authentication. Supabase handles the database. But Supabase's Row Level Security uses &lt;code&gt;auth.uid()&lt;/code&gt; which expects Supabase Auth — not Clerk. So RLS policies blocked all reads and writes even for authenticated users.&lt;/p&gt;

&lt;p&gt;The fix was to use the Supabase service role key in all server-side API routes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/supabase-admin.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@supabase/supabase-js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;supabaseAdmin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NEXT_PUBLIC_SUPABASE_URL&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SUPABASE_SERVICE_ROLE_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The service role client bypasses RLS. I use it in all API routes where I verify the user via Clerk first, then query Supabase with elevated permissions.&lt;/p&gt;

&lt;p&gt;The regular anon client is used only for Supabase Realtime subscriptions on the client side — where I do not need to read or write protected data.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Session Replay Timeline
&lt;/h2&gt;

&lt;p&gt;After each session, users can replay their entire argument history. Every turn is saved with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The argument text&lt;/li&gt;
&lt;li&gt;The score for that turn&lt;/li&gt;
&lt;li&gt;The score delta — how much it went up or down&lt;/li&gt;
&lt;li&gt;Whether it was a defense turn or witness turn&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This data drives a visual bar chart and a turn-by-turn timeline showing exactly where the user won or lost the case.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;newEntry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;turn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scoreHistory&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;argument&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;currentInput&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scoreDelta&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;defense&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;updatedHistory&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...(&lt;/span&gt;&lt;span class="nx"&gt;scoreHistory&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[]),&lt;/span&gt; &lt;span class="nx"&gt;newEntry&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The best argument is calculated on every save:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bestArgument&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;updatedHistory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;updatedHistory&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Security
&lt;/h2&gt;

&lt;p&gt;Three things I implemented before deploying:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Row Level Security on all tables&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Even though I use the admin client in API routes, RLS is enabled on all tables as a defense-in-depth measure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Rate limiting&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Each user is limited to 50 API calls per hour. This prevents prompt injection attacks and runaway API costs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Input sanitization&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;All user inputs are limited to 1000 characters before hitting the AI. This prevents prompt injection and keeps costs predictable.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Structured JSON outputs are underrated.&lt;/strong&gt; Using &lt;code&gt;response_format: { type: "json_object" }&lt;/code&gt; eliminated an entire category of bugs. No more regex parsing, no more broken responses, no more try-catch around JSON.parse for normal flow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Supabase Realtime is genuinely magical.&lt;/strong&gt; Building multiplayer with WebSockets from scratch would have taken weeks. With Supabase Realtime it took two hours. The postgres_changes subscription is one of the most elegant APIs I have used.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The hardest part of AI products is not the AI.&lt;/strong&gt; It is the state management around the AI. Keeping conversation history consistent, handling loading states, recovering from errors gracefully — that is where the real engineering work is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ship with security from day one.&lt;/strong&gt; I added RLS and rate limiting before the first deployment. Going back to add security to a running production app is much harder than building it in from the start.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Is Next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Voice arguments — speak your legal argument instead of typing&lt;/li&gt;
&lt;li&gt;AI feedback report — a full written analysis of your performance after each session&lt;/li&gt;
&lt;li&gt;More landmark cases — currently at 10, targeting 50 by end of year&lt;/li&gt;
&lt;li&gt;Mobile app — React Native version for studying on the go&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Live:&lt;/strong&gt; &lt;a href="https://lexai-fd92.vercel.app" rel="noopener noreferrer"&gt;lexai-fd92.vercel.app&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/naimakader/Lexai" rel="noopener noreferrer"&gt;github.com/naimakader/Lexai&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you are a law student, try arguing State v. Miranda. If you are a developer, look at the battle mode and the OG image generation — those are the two parts I am most proud of technically.&lt;/p&gt;

&lt;p&gt;Questions welcome in the comments.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built by Naima — frontend developer who ships full-stack products from Jijiga, Ethiopia.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>typescript</category>
      <category>nextjs</category>
      <category>openai</category>
    </item>
  </channel>
</rss>
