Repository navigation
chore(deps): bump uvicorn from 0.35.0 to 0.52.4 in /backend/quiz-engine - #33
Open
dependabot[bot] wants to merge 159 commits into
Open
dependabot[bot] wants to merge 159 commits into
dependabot[bot] wants to merge 159 commits into
Conversation
Rebrand to the real Ohmlet tokens (cream/ink/gold, Nunito), add 14 looping SVG explainer animations for the significant tasks (mount on card expand, motion.dev with a static fallback), and a full, research-backed task list grouped into themed sections.
Add consent-gated build_complete and first_build_complete events and a northstar helper that computes days_since_signup and fires the FBC7 numerator once per learner. Wire it into the finished-build capture in the live tutor, carrying session length.
Add push-button LED (digitalRead, pull-down) and potentiometer dimmer (analogRead, map, PWM) build guides, matching the existing guide bar with parts tables, working code, troubleshooting, and FAQs.
The brand ink #14201e (rgb 20,32,30) was green-dominant. Swept it to #14181f (subtle navy-black, same darkness, zero green) across the Tailwind tokens, CSS variables, every hard-coded literal (simulator, code lab, lesson runner, free-form editor, challenge art, avatar, author preview), the rgba(20,32,30) shadow tints, and the build report. ink-soft #46514e -> #474d57.
…ixes - Add blogArt.ts: 11 hand-authored on-brand SVG diagrams (Ohm's law, breadboard anatomy, wiring diagrams, resistor color code, PWM, multimeter, etc.), rendered inline so labels inherit Nunito. posts.ts media blocks gain an 'art' id and BlogPostPage renders the diagram, keeping the placeholder as fallback. - Fix the grid-card corner gap: drop overflow-hidden and round the color band's own top corners, eliminating the border-radius clip seam (verified at 8x). - Fix the Ohm's-law burned LED so its legs wire into the circuit.
…ild div The grid cards showed a 1px white seam in the top corners on some cards but not others (same swatch, same component) - a sub-pixel antialiasing gap between the band's rounded-top corner and the card's rounded corner, which only aligns cleanly at certain fractional pixel positions down the page. Neither a ring nor matching the band's radius fixed it. Structural fix: drop the child band entirely and paint the gradient as the card's own background (bg-gradient + background-size 100% 7rem, no-repeat, top). Now there is a single rounded element and no second corner to misalign, so a seam is impossible at any pixel position or device-pixel-ratio. Verified at 4x with a deliberate 0.5px offset and in the built app.
…n, twin share The July 11 sprint. Ships the web app's safety, money, and store-readiness spine. Child mode (#94): parent consent flow (Stripe Checkout setup-mode provider, swappable to KWS), server-side consent gate at the live-bridge handshake, a child-safe agent (hazard refusals, no PII, no emotion inference, BLOCK_LOW safety), child-safe live defaults (rear-camera lock, mic/cam off, session cap), and per-user age scoping. Ships dark behind OHMLET_CHILD_MODE / VITE_OHMLET_CHILD_MODE. Monetization + trust: - First-build conversion moment / paywall (#18) with BuildCelebration. - Under-18 payment gate (#96): purchases blocked for minors. - In-session AI safety acknowledgement (#98). - Community moderation (#97): report/block, auto-hide threshold, minor lockdown. 3D-twin public sharing (#79): reporter share/unshare + unguessable public share id; client share API. The premium public /t/:id page is the next piece. Legal (#94): Terms/Privacy children + parental-consent updates staged behind the flag; must not deploy ahead of solicitor sign-off (#99). Validation: frontend `npm run build` green; `py_compile` green on all backend files. Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
Closes #79. The reusable virality asset: a completed twin gets an unguessable public link, and the page behind it is a real brand surface, since for most visitors it is the first thing they ever see of Ohmlet. SharedTwinPage: public /t/:shareId, no auth. The model is the hero on a full-width dark stage with a drag-to-spin affordance; the caption bar is welded to the stage rather than floating as its own card. Below it, the conversion beat: what Ohmlet is, a numbered three-step strip, and a closing CTA band. Three real states, including a purposeful dead end (mascot + a way in) for an expired or unshared link. App: /t/:id resolves ahead of the auth gates so a cold visitor never sees a splash; signed-out CTAs go to signup, signed-in go to the workspace. TwinStudio: a share strip once a twin is ready. Publishes, copies the link, and always shows the URL (clipboard can be refused), plus Stop sharing. TwinViewer: optional margin prop (default unchanged). The hero passes 1.7, since a full-bleed stage and narrow portrait viewports clipped the model at 1.2. Analytics: twin_shared, shared_twin_view, shared_twin_cta make the loop measurable end to end. Validation: npm run build green (page code-splits to 2.41 kB gzip). Visually verified in Chromium at 1440x900 and 390x844 against mocked public endpoints with a real GLB: ready, mobile, and not-found, no console errors. The mobile clipping was caught this way and fixed. Hosting already rewrites ** to /index.html, so no config change is needed. Not yet clicked through end to end: the TwinStudio share strip needs a signed-in session and a configured reporter, so it goes in the deploy smoke test. Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
… fake data Findings from a full-codebase audit. Every item below was verified against the running code (or the live deployment) before being changed. CRITICAL - resilience: add the missing CircuitBreaker.call(). Three production call sites (reporter twin generation, interview report, resume extraction) called a method that did not exist. The AttributeError was swallowed by a broad `except Exception` and surfaced as 502/503, so the 3D digital twin and interview reports have NEVER worked. Applied to all five service copies, which stay byte-identical, plus regression tests so it cannot silently regress again. - live tutor: the production bundle hardcoded ws://localhost:8082 because VITE_OHMLET_WS_URL was never set, so the Build tab and Interview Mode could not connect at all (also blocked as mixed content). The socket URL is now derived from the live-bridge REST base, which is already configured; in a production build with neither set it throws at load instead of silently shipping localhost. - quiz-engine: /generate and /assess-drawing were fully unauthenticated on a service deployed --allow-unauthenticated, i.e. an open Vertex endpoint on our billing account with attacker-controlled prompt text. Added the standard auth + per-identity rate-limit guard, the firebase-admin dependency, pinned requirements to match the other services, and made the client send its token. - auth: is_admin granted admin to any token whose email matched the allowlist with no email_verified check, so an unregistered allowlisted address could be claimed by anyone and self-grant Max. Now requires a verified email; the custom claim remains the real mechanism. Applied to all five copies. HIGH - idempotency: claim_event returned False on a Firestore error, which the Stripe webhook read as "already processed" and acked 200, so a transient blip meant a customer paid and was never upgraded, with no retry. It now raises so Stripe redelivers. - live-bridge: Starlette RETURNS the websocket.disconnect frame rather than raising, so every clean hang-up fell through and was logged as an ERROR, burying real failures in 5xx alerting. Handled explicitly. Fake data shipped to real users (violates the project's own no-placeholder rule) - Every user was greeted "Welcome back, faith." Now uses their display name. - The weekly league card showed a hardcoded "Copper League, Rank #4" to everyone, contradicted by the real leaderboard one click away. Now driven by the real fetchLeaderboard(), and hidden entirely when there is no data rather than inventing a position. - "Recent achievements" showed two invented achievements to brand-new accounts. Now lists only genuinely earned ones, with an honest empty state. - The "pick up where you left off" progress bar was a hardcoded 45%/15%. Now the real completed-over-total fraction. Validation: all 99 backend tests pass (live-bridge 59 + 2 new, reporter 18, vision-verifier 15, compiler 7); py_compile clean on all five services; `npm run build` green; verified the built bundle no longer contains ws://localhost or the hardcoded name/league. Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
… deploy Two audit findings, both money/production-critical. 1. Live-session budget bypass (margin hole) A session read its remaining allowance ONCE at connect and only wrote its consumption back at close, while the watchdog compared that frozen snapshot against this session's own clock. Two concurrent sessions (two tabs, phone plus laptop) therefore each saw the full remaining balance and each spent it, so one account could multiply live-tutor Gemini spend past the plan's margin with no exploit beyond opening another tab. Sessions now settle as they run: entitlements.settle_live_session() bills only the not-yet-charged delta and re-reads the shared monthly counter, so siblings become visible within one sync interval (OHMLET_BUDGET_SYNC_SEC, default 30s) instead of never. Teardown settles the remainder through the same path, so a long session is never billed twice for the same seconds. The settle runs via asyncio.to_thread because the Firestore client is synchronous and that loop also carries live audio. The accounting moved into entitlements.py (where the money logic belongs) so it is unit-testable rather than trapped in a closure. New tests pin both invariants: never double-bill on repeated settles, and two concurrent sessions consuming the same allowance see each other and the second one is cut off. 2. deploy.sh never deployed the frontend or the Firestore rules deploy_frontend() built the bundle and then only PRINTED "deploy dist/ to your hosting provider", so ./deploy.sh all updated five Cloud Run services and left the live site untouched. The Firestore rules were never pushed at all, meaning the deny-all rules in git were not proven to be what is live. It now runs `firebase deploy --only hosting,firestore:rules`, failing early with a clear message if the firebase CLI is absent. The rules ship with the app that depends on them instead of drifting. It also refuses to deploy a bundle containing a localhost:PORT service URL. That is the exact failure that put ws://localhost:8082 into production: a missing VITE_* key falls back to a dev URL. Verified in both directions - the guard passes on a correct build, and blocks a build made with a service URL removed. A bare "http://localhost" (the Firebase Auth SDK's popup constant) correctly does not trip it, which is why the port is required. Added a hosting predeploy hook so a bare `firebase deploy` rebuilds rather than publishing a stale dist/ from an older branch. Validation: 103 backend tests pass (live-bridge 63, reporter 18, vision-verifier 15, compiler 7); npm run build green; bundle verified clean. Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
…ated content
Both public pages presented invented content as real, to people deciding whether
to pay. Once money changes hands that is a consumer-protection problem (FTC
16 CFR 465, UK DMCC 2024, EU UCPD Annex I), not just a house-style violation.
Removed:
- Six invented builders ("Priya N.", "Marcus T.", ...) with invented engagement
counts (214 likes, 38 comments), under the headline "Built by the community /
Real circuits from real learners".
- Three invented testimonials with names, quotes and occupations, including a
named high-school teacher.
- A hardcoded five-star rating with no reviews behind it.
- Four "build paths" (First Light, Sense the World, Make It Move, Code Your
Circuit) with invented build counts. None of them exist in the curriculum.
Replaced with the data the product actually ships, which is both true and a
better pitch:
- /build renders BUILD_LIBRARY: six real projects with their real parts lists,
time estimates, target board and difficulty, filterable by real level. The
testimonial block became "What happens when you go live", describing three
shipped capabilities (camera parts check, live wiring correction, voice Q&A
and sketch debugging) rather than claims about other people's experiences.
- /learn renders CURRICULUM: the real 12 units in unlock order with their real
subtitles and levels, and the lesson total computed from allLessons() so the
public page can never drift from what a learner receives.
Deliberately no builder names, likes, comments, ratings or reviews anywhere:
there is no community data yet, and the honest version is the one that survives
first contact with a paying customer.
Validation: npm run build green; both pages rendered at 1440x1000 with no
console errors and visually checked.
Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
… defects Audit findings in the frontend. Ordered by what they cost a real user. Camera and microphone could stay live after being turned off toggleCam/toggleMic gated on the camOn/micOn STATE, which only flips after getUserMedia resolves. The permission prompt stays open for seconds and the control stays clickable, so a second tap started a second stream that overwrote camStreamRef and camIntervalRef. The first stream's tracks were never stopped and its interval never cleared: the UI said "camera off", the OS camera indicator stayed lit, and frames kept uploading to Gemini for the rest of the session. Added in-flight guards to both toggles, made startCameraStream stop any stream it is about to replace, and made the failure paths tear down whatever was half-opened. Shared-device state leaked between accounts Ohmlet is explicitly aimed at family and classroom machines, where device-global keys hand one person's state to the next: - ohmlet.safetyAck.v1 meant a user who had never seen the mains-power and AI-fallibility warning could start a live session without it. - ohmlet.ageGate.v1 meant a child could inherit an adult's birth year (unlocking the front camera, community and self-purchase), or an adult could inherit a child's and be stuck at the parental-consent screen. - Nothing was cleared on sign-out, and account deletion left the birth year, plan and progress on the device while promising to delete personal data. Added services/localState.ts (per-user keys, safe read/write, and a cleanup that runs on sign-out and deletion). Cookie consent is deliberately preserved: it is a device-level legal choice, not a per-account preference. Auth and plan defects - getIdToken rejected on network failure while every service client awaits it OUTSIDE its own try/catch, so the community feed and plan lookup could spin forever. It now resolves null, fixing all nine callers at one point. - Google sign-up always reported isNewSignup=false because the credential was discarded, so new Google users skipped onboarding entirely. Now derived from getAdditionalUserInfo().isNewUser, and it emits sign_up rather than login. - A Max subscriber who ran out of live time was told to "Upgrade to max", the plan they already pay for. Top-tier and under-18 accounts now get an honest reset message and no upsell button. - PLAN_META.max.label was lowercase, rendering "max plan" across the sidebar, account page and paywall. Runaway emulator CodeLabView started its rAF loop after an awaited compile with no mounted check, and unmount cancelled only the rAF that existed at that moment. Leaving the Code tab mid-compile left a 16MHz emulator and five setState calls per frame running on a dead component, unreachable because the Stop button was gone. Added a mounted flag checked after every await. NOT fixed here, and tracked instead: 24 achievements remain unearnable because 9 of the 13 metrics (live sessions, drawings, perfect builds, twins, likes, posts, comments, challenges, league wins) have no tracking anywhere in the codebase. That is a feature to build, not a bug to patch, and faking the numbers would be worse than leaving them locked. Validation: npm run build green; 63 backend tests pass; all ten public routes smoke-tested in Chromium with zero console or page errors, and /login verified to still render working email and password fields. Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
…lly does A privacy policy is a binding representation. The live one at /privacy promised protections the code does not provide, which is a legal exposure rather than a copy problem. Each item below was checked against the code before changing. Removed claims the product does not keep: - "We keep a text transcript of the session so you can review it." Live transcripts are React state in useLiveBridge and are never sent to or stored by us; a user exercising a subject-access request would receive nothing. Now stated accurately, and the retention table says transcripts are not retained at all, which is both true and a better privacy position. - "The camera is off by default. A session is voice-first." startSession() auto-enables microphone AND camera for every non-child session. Replaced with what actually happens: nothing starts until you press Go live, the browser asks permission, both switch on because the tutor needs to see the bench, and either can be turned off mid-session. - "our registered company details will be listed here once incorporation completes" was a live placeholder in a published legal document. - A child-mode notice advertised "content safety at its strictest level". The audit established those safety settings never reach the model on the live path (LiveConnectConfig has no safety_settings field), so the claim is now limited to the child instruction set and community lockdown, which are real. Shorter retention for children was also claimed and is not implemented. Gated the children's sections on the feature flag: The Privacy §10 and Terms §2 child text described a parental-consent gate while OHMLET_CHILD_MODE is off, so today a minor could sign up and get the full camera-and-voice tutor with no age check at all, in direct contradiction of the published policy. Both now render from CHILD_MODE_ENABLED: with the flag off they state the accurate position (accounts require the local age of digital consent, we do not knowingly collect a younger child's data, purchases require 18+), and the full parental-consent text returns automatically with the feature. STILL FOR FAITH: GDPR Art 13 requires the controller's identity and contact details. Section 1 names Ohmlet and gives an email but no legal entity or address; that must be completed before taking real payments. Noted at the top of the file, and solicitor review remains task #99. Validation: npm run build green; the rendered /privacy asserted against eight checks in Chromium (each removed claim absent, each replacement present, no unresolved template placeholders, updated date shown). Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
24 of the 50 achievements could never unlock. The workspace supplied only xp, streak, builds and units, so every achievement about live sessions, drawings, perfect builds, twins, posts, comments, challenges and league finishes sat at zero permanently, no matter what the learner did. Gamification is a stated retention pillar, so half the trophy case being unreachable is a real product defect rather than cosmetic. Approach: emitters publish an event, ONE listener owns the counters. - services/achievementEvents.ts: recordMetric() / onMetric(). Avoids drilling a setter through half the view tree, and keeps a single writer so the debounced save cannot race itself. - hooks/useAchievementMetrics.ts: persists the counters in their OWN state document (key 'metrics'), not inside the progress envelope, so the two saves are independent. Per user rather than per device, so a second device does not reset them. - useOhmletUserState.updateState now also accepts an updater function. Counters need it: two increments in the same tick would otherwise both read the same stale value and one would be lost. Wired at the points where the events actually happen: live sessions (session start), twins (successful generation), drawings (a drawing the grader accepted), perfect (a lesson completed with no wrong answer, tracked via the requeue path), posts / comments / challenges (only on a confirmed server response), and league wins (a top-three finish, keyed by the leaderboard's own week id so one week can only ever count once). Preview mode never records, since the author console renders lessons without a real learner. Result, measured against the achievement table: 47 of 50 now reachable, up from 26. The remaining 3 are the "likes received" tier, deliberately left out. Another person liking your post is not an event this client observes, and summing it from the feed would depend on the author uid that the feed should stop exposing for privacy reasons. It needs a small per-user community stats endpoint, which is tracked separately rather than approximated. Validation: npm run build green; 142 lessons lint clean; 63 backend tests pass. Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
…t address Closes the last item the audit left open in the published policy. Controller identity (GDPR Art 13). Section 1 previously said only that Ohmlet is the controller and is "operated from Ireland", which left a user or regulator unable to establish who actually controls their data. It now states plainly that Ohmlet is an independent service operated from Ireland and NOT yet incorporated, gives a monitored mailbox as the contact point, and undertakes to provide full controller details in writing on request. This is a deliberate founder decision, recorded in the file header: no personal name and no home address on a public, scrapeable page while Ohmlet trades as a sole trader. It is defensible for a small online service but not settled, since a regulator may expect a named controller and a postal address. The header names the two things that close it properly (incorporating, or a registered address if launching first) and points at the solicitor review in task #99. Dead contact address. The policy advertised privacy@ohmlet.org in six places and the child-mode parent notice in a seventh, but that mailbox does not exist. Data subject-access, deletion and parental-rights requests sent there would have bounced or vanished, and answering them is a legal obligation with a statutory deadline, so an unread address is worse than none. PRIVACY_EMAIL now points at the mailbox that exists (hello@ohmlet.org) and the child notices import the same constant, so the two can no longer drift. One line changes it back when a dedicated privacy alias is created. Note: the address given in the request was written as ohmlet.com; the domain used throughout the repo and CLAUDE.md is ohmlet.org, so .org was kept rather than silently introducing a new domain. Worth confirming. Validation: npm run build green; privacy, terms and cookies rendered in Chromium and asserted against seven checks (dead address absent everywhere, live address present, controller status stated, details-on-request present, no personal name published, no unresolved template placeholders, DPC complaint route intact) with no page errors. Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
Follows the founder's decision to name herself as controller while keeping a home address off a public page. Section 1 now reads: Ohmlet is operated from Ireland by Faith Ogundimu as a sole trader, is not yet incorporated, and Faith Ogundimu trading as Ohmlet is the data controller. Contact remains the monitored hello@ohmlet.org, with full controller details available in writing on request. Naming the controller is the part of GDPR Art 13 that actually lets a user or a regulator establish who holds their data, and that is now satisfied. The postal address is deliberately withheld: for a sole trader that is a home address, and a public page is scraped and archived, so this is a personal safety decision rather than a compliance oversight. It is recorded as such in the file header, with the two routes that close the remainder (incorporation, or a registered address) left on the solicitor review in task #99. Domain confirmed as ohmlet.org, so no change was needed there. Validation: npm run build green; /privacy rendered in Chromium and asserted against six checks (controller named, sole-trader status stated, not-yet- incorporated stated, contact email present, no street or postal address published, dead privacy@ address absent). Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
First increment of the mobile app for Shipaton. Expo SDK 57 / React Native 0.86 / expo-router, TypeScript strict, targeting org.ohmlet.app. Talks to the SAME backend and the SAME Firebase project as the web app, so an account created on either surface works on both and the backend's per-user isolation applies unchanged (it derives the UID from the verified token and never trusts the client). What is here: - Brand tokens copied verbatim from frontend/tailwind.config.js, so the two surfaces are visually continuous rather than approximately similar. - Firebase Auth with AsyncStorage persistence via getReactNativePersistence. Without that the session is dropped on every app restart, which is the most common Expo + Firebase mistake. - getIdToken resolves null rather than rejecting, carrying over the fix from the web audit: every service client awaits it outside its own try/catch, so a rejection there left screens spinning forever. - Service config derives the WebSocket origin from the REST base instead of keeping a second env var. That is the exact failure that shipped ws://localhost:8082 to web production and left the live tutor dead for every user; one value means one failure mode. - Auth context that bumps a version counter on profile update, because Firebase mutates the same user object in place and a plain memo would keep showing the raw email for the whole session (a real defect found in the web app). - Sign in / sign up with an in-flight guard, per-field labels, honest error copy mapped from Firebase codes, and a live region for screen readers. - A Button that reproduces the web app's hard-offset press treatment, since RN has no shadow equivalent: the face animates down into a static plate. - Home screen greeting the real display name. Never a hardcoded one. Deliberately NOT here yet: the paywall is stubbed until Faith supplies a RevenueCat public SDK key, and the live tutor, lesson runner and curriculum fetch land next. react-native-purchases is installed but unwired; it is a native module, so this app needs a development build rather than Expo Go. Validation: tsc --noEmit clean; `expo export --platform ios` succeeds (2.7MB bundle). Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
Its own header says to delete it after use. It moves src/ and scripts/ into example/ and regenerates a blank app — destructive to run by accident, and useless now the real app exists. Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
… screens (#70)
Executes the decision in metadata/decisions/2026-08-22_curriculum-client-vs-backend.md,
sequenced with the mobile build as planned.
Why now. The lessons were compiled into the web client. That is fine on the web,
where a fix is a deploy, but on mobile it would be an App Store review — days to
correct a typo in one question. Serving the content means a lesson fix reaches
both surfaces at once, and keeps the authored curriculum (the moat) out of any
client bundle.
Backend:
- frontend/scripts/export-curriculum.mjs bundles the TypeScript source with
esbuild and evaluates it, rather than parsing it by hand, so the emitted JSON
can never drift from what the web app actually compiles. Exports 12 units and
142 lessons with a content hash for cache invalidation.
- backend/live-bridge/app/curriculum.py serves /v1/curriculum/{version,manifest,
lessons/{id}}. The manifest deliberately excludes lesson bodies so a client can
render the path and decide what to download without pulling ~700KB.
- Lesson ids are authored, human-readable strings containing spaces, so they are
matched verbatim rather than slugified — the same class of mistake that broke
twin share ids once already.
Mobile:
- services/curriculum.ts is cache-first: serve the cached copy immediately, ask
for the version (a tiny request), refetch only on change. The app therefore
works offline on a train, and a corrected lesson still lands next launch.
- Learning path, unit detail, and a lesson shell, all on real backend data.
- Failure states are honest: an unreachable backend and an empty path do not
render identically, which is a mistake the web community feed made.
- services/billing.ts loads react-native-purchases LAZILY. It ships native code,
so a static import crashes Expo Go — and Expo Go is how Faith reviews progress
on her phone. Every entry point degrades to a stated reason instead of a dead
button.
Validation: 68 backend tests pass (5 new, including a guard that every lesson the
manifest advertises actually has content, and that both files agree on version);
tsc --noEmit clean; expo export succeeds (2.8MB).
Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
Expo Go on Faith's phone is ~SDK 54 (last App Store update ~10 months ago) while the project is on SDK 57, so Expo Go refuses to open it. Rather than downgrade a year of SDK for a workflow that dies anyway the moment RevenueCat lands (native module, unloadable in Expo Go at any SDK), the browser preview gives the same code and the same design with zero setup, and the development build becomes the real device path once the Apple account exists. Verified: renders at iPhone dimensions with no console or page errors, and the sign-in / sign-up toggle works. Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
Faith hit "Project is incompatible with this version of Expo Go". Cause was mine: I scaffolded on SDK 57 (current, June 2026) while her Expo Go — last updated on the App Store ~10 months ago — is around SDK 54. Expo Go only runs the SDK it shipped with. Downgraded at her explicit request, knowing the trade: SDK 54 is a year old and we re-upgrade when the development build lands. It buys device testing now, which is worth more than SDK currency while the app is still being shaped. - expo 54.0.37, react 19.1.0, react-native 0.81.5, expo-router 6.0.24, and every Expo package realigned by `expo install --fix`. - @types/react and typescript pinned to what SDK 54 expects (5.9, not the 6.0 that had been resolved) — mismatched type versions cause failures that only surface at build time. - Added .npmrc with legacy-peer-deps. expo-router 6 declares an OPTIONAL peer on react-server-dom-webpack that resolves to a React newer than SDK 54 pins, so a plain `npm install` fails with ERESOLVE. The dependency is optional and unused, so relaxing resolution beats forcing a React mismatch. Verified: the dev server now advertises `exposdk:54.0.0` in the manifest Expo Go actually reads; tsc --noEmit clean; expo export succeeds (2.91MB). Unchanged by this: RevenueCat still cannot run in Expo Go at any SDK version, so the paywall still needs a development build. Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
… (#70) The app opened straight onto a login form, which reads as a web page in a phone frame rather than an app. There was no front door and no way in other than email/password. Added: - welcome.tsx — the front door. Mascot, wordmark, value proposition, and two clear ways forward. One signature motion: a staggered rise, mascot first, rather than many small animations. - onboarding.tsx — a three-beat tour (it sees your bench / it talks back / you keep the build), paged horizontally with a progress indicator and a skip. Every claim is a shipped capability; no promises the product does not keep. - sign-in.tsx rebuilt as an auth CHOICE screen: Apple, Google, then email behind a disclosure so the form is not the first thing a person meets. - socialAuth.ts — Apple and Google bridged into the same Firebase user the web app already uses, so an account works across both surfaces. App Store guideline 4.8 shapes the ordering: an app offering ANY third-party sign-in must also offer Sign in with Apple. Offering Google without it is a rejection at review, so Apple ships first and is listed first. Apple sign-in generates a raw nonce and sends only its SHA-256 to Apple, so Firebase can verify the raw value against the hash. Skipping that is what makes an Apple sign-in replayable. The full name is also captured on first authorisation only, because Apple never returns it again. Both providers degrade honestly: Apple hides where unavailable, Google hides without a configured client id, so neither ever renders as a button that does nothing. firstRun.ts stores the tour flag DEVICE-scoped, deliberately unlike the age gate and safety acknowledgement which are per-user: whether the tour has been seen is a property of the install, and signing out should not replay it. Assets: mascot poses and the wordmark copied from the web app and resized for mobile — 1.8MB total against 7MB for the source mascots alone. Verified: tsc clean, expo export succeeds (3MB), all three screens render at phone dimensions with no console or page errors. Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
The onboarding slides had dead space below the copy. Each now carries an animated scene showing the mechanic the text describes, rather than decoration: - BoardScanScene: a breadboard with a scan sweeping down it and a component lighting up as the scan reaches it. The board and hole grid are drawn, not an image, so they stay crisp at any size. - VoiceScene: a staggered waveform. Bar heights are a fixed table, not random, so it renders identically every time. - TwinScene: an isometric wireframe rotating slowly. Suggests the 3D twin without pretending to be a real render. Remotion was suggested but does not apply — it renders React to MP4 for video and has no mobile runtime. react-native-svg plus reanimated is the equivalent here, runs on the native thread, and works in Expo Go. Mascot art shrunk to make room for the scenes. Verified: renders with no console errors, tsc clean. Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
The Duolingo-style run, ported properly rather than approximated. The run is a QUEUE of step indices, not a straight walk. A wrong graded answer is pushed to the BACK of the queue so it returns later in the same run and must be cleared. That requeue is what makes a run teach rather than merely test, and it is the mechanic the whole curriculum was authored against. Progress is measured in MASTERED steps, so a requeued step holds the bar rather than letting it march on. Five hearts; running out offers a retry rather than a dead end. Step types implemented: teach, multiple_choice, true_false, fill_blank (typed or tile bank), match, drag_order, predict_reading, predict_behavior, choose_resistor, identify_component. Measured against the authored data, those cover 2,355 steps at 88%. The drawing family (draw_circuit, draw_fix, draw_connection — 6.2%) needs a canvas and lands separately; unsupported types are filtered out of the run rather than rendered broken, so the step count always matches what the learner actually sees. Two real bugs found and fixed while writing useRun, both of which would have surfaced as skipped questions: - advance() called setDone/setPos INSIDE a setQueue updater. React may invoke an updater twice, so under StrictMode that double-incremented the position and skipped a step. The next queue is now computed as a pure value first. - The lesson re-seed ran during render. setState while rendering is a React error and made the hook order-dependent; moved into an effect. Verified the requeue invariant by simulating advance() exactly: all-correct runs in N advances, a once-wrong step reappears at the end ([0,1,2,3,4,2]) and is then mastered, a twice-wrong step reappears twice, and a never-mastered step can never let the run finish early. Progress persists through the SAME /v1/state envelope the web workspace uses, so XP, streak and completed lessons follow a person between surfaces instead of forking. Re-completing a lesson never re-awards XP and never demotes a better previous result. The completion write is guarded so a re-render cannot double award. Verified: tsc clean, expo export succeeds (4.32MB). Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
Home, rebuilt on real data. It previously showed a hardcoded "Light-Activated Alarm" card and a "coming next" note — the same class of placeholder that was stripped out of the web marketing pages, and it should not have gone in. It now reads persisted progress and the curriculum manifest to show actual XP, streak, lessons completed out of the real total, and the genuine next unfinished lesson. Path completion is computed, not decorative. Pull to refresh, and it re-reads on focus so finishing a lesson is reflected immediately. Offline and empty states are distinct, so an unreachable backend never reads as "nothing here". Live tutor, talking to the deployed live-bridge over the same protocol as the web client: auth frame first, then text / image / stage frames, with ADK events carrying the agent's text and audio back. - The camera feed is the hero and fills the viewport, with the stage selector and transcript floating over it rather than taking space from it. - Frames go up on a heartbeat while the camera is on, plus an explicit "look now". - Agent audio plays for real. The agent streams raw 24kHz PCM, which browsers can hand to an AudioContext but React Native cannot, so each chunk gets a WAV container built by hand (no Buffer or atob in the RN runtime) and is played via expo-av. Chunks replace rather than layer, since they arrive faster than they play and overlapping them turns speech into noise. - Verified the byte work rather than trusting it: the base64 encoder matches Node's Buffer exactly, decode round-trips, and the generated WAV parses back with correct RIFF/WAVE/fmt/data tags, sample rate, channels, byte rate, data size and payload. MICROPHONE is deliberately not wired. Real-time PCM capture needs a native module that Expo Go cannot load, so the pre-flight screen says so plainly instead of showing a dead button: the camera and the tutor both work now, questions go up as text, and replies come back as speech. Capture drops in when the development build exists. Teardown closes the socket and unloads audio on unmount — an orphaned socket keeps a metered session open, and an orphaned sound holds the audio session. A stale close cannot orphan a newer socket. Verified: tsc clean, expo export succeeds (4.41MB). Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
Monetization, which Shipaton requires and the HAMM category judges. - entitlements.ts / usePlan read /v1/me, the same server-authoritative endpoint the web app uses. The client never decides what someone is entitled to: the plan and remaining live budget come from Firestore and the Stripe webhook, and the live-bridge enforces the same cap again at the socket. This layer only displays state. Deliberately uncached — a stale entitlement either shows a paid feature to someone who stopped paying or hides one from someone who just upgraded, and both are worse than a brief spinner. - plans.tsx renders RevenueCat offerings. Prices are NEVER hardcoded: App Store Connect owns them, they differ by storefront and currency, and a hardcoded figure would be wrong for most of the world and drift the moment it changed. Perks are stated, prices come from the store. - Purchase re-reads /v1/me rather than assuming the entitlement landed, because it is granted server-side by webhook. - Restore purchases is reachable in-app, which Apple requires, and the renewal and cancellation terms are stated on the screen. - The live tutor now shows remaining minutes before the camera opens, and routes to plans when the month's budget is spent rather than failing at the socket. Every unavailable path explains itself: no RevenueCat key, running in Expo Go, or no offerings configured each produce a specific sentence instead of a button that does nothing. Verified: tsc clean, expo export succeeds (5.71MB). Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
…(#70) Four features, each complete rather than sketched. DRAWING STEPS — lesson coverage 88% -> 98.6% - draw_connection: terminals rendered from the authored coordinates, tap two to wire them. Connections are undirected, so the grader sorts each pair rather than demanding the learner tap in the author's order. - draw_circuit / draw_fix: a freeform canvas of SVG paths (not a bitmap, so undo is a pop and the capture stays crisp), graded by the quiz-engine vision model. A tap with no movement is not counted as a stroke, otherwise "has ink" would lie. - When the grader is unreachable the attempt is ACCEPTED and says so. Being told you are wrong because a service is down is the worst outcome available here. - Remaining 1.4%: trace_current, spot_error, fix_the_circuit, build_to_spec (32 steps), all of which need interactive circuit-diagram rendering. ACHIEVEMENTS — 47 of 50 reachable, matching web - The catalogue is served from the backend beside the curriculum, so a new achievement does not need an App Store review. Which ones are EARNED is computed on-device; the catalogue is identical for everyone. - Metrics now tracked on mobile: liveSessions (counted when the socket actually connects, not when the button is pressed), drawings (graded correct), perfect (a run cleared with no wrong answer), posts, comments, challenges, leagueWins (once per league week, keyed by the week the board reports). - Locked cards show a real progress ring. The three 'likes received' cards say plainly that the metric lives server-side rather than showing a ring stuck at zero — that needs the community stats endpoint. COMMUNITY - Feed, threaded comments, likes, challenges and the weekly league, plus report and block. - Every call returns a discriminated result, so "empty" and "unreachable" cannot render identically. The web app returned null on error and showed a cheerful "be the first to post" during outages; that bug is not repeated here. - A child account is refused server-side by require_non_minor; the screen states that rather than showing an empty feed that looks broken. 3D TWIN VIEWER - Real three.js on the device GPU via expo-gl. The GLB is fetched with auth, so GLTFLoader.parse() takes bytes rather than .load() taking a URL. - Auto-framing: any mesh the generator returns is centred and scaled to a fixed size, so every twin fills the view identically. - Drag to orbit with clamped pitch so the model cannot flip; it also idles slowly, so a still screenshot still reads as 3D. - The render loop is cancelled on unmount — an orphaned rAF keeps the GPU busy and holds the whole scene alive. - Meshes load only when a twin is opened, not to render the list. - Sharing goes through the existing share endpoint and the iOS share sheet. - The reporter is still undeployed, so the screen states why instead of offering a button that has never worked. Verified: 70 backend tests pass (2 new on the achievement catalogue), tsc clean, expo export succeeds (8.96MB), achievements endpoint live and authenticated. Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
Xcode is installed, so the app now builds and runs as a real native binary
rather than inside Expo Go. That removes the constraint that blocked two
features: native modules load, so the RevenueCat paywall and eventually real
microphone capture become testable.
Two genuine defects surfaced only once a native build was attempted, neither
visible to tsc or to the JS bundler:
- @expo/ui was a CANARY prerelease left by the SDK 57 scaffold, still present
after the downgrade to 54. Its Swift referenced ExpoModulesCore symbols that
do not exist in 54 ('SafeAreaControllable', 'RNHostViewProtocol'), which failed
the native compile. Nothing in src/ imported it, so it is removed. No canary or
prerelease dependency remains.
- expo-av is deprecated and slated for removal, and it was carrying the live
tutor's audio playback. Migrated to expo-audio: createAudioPlayer for the
imperative path (playback happens in a socket callback, not a render), and
setAudioModeAsync({ playsInSilentMode: true }) so the tutor is still audible
with the ringer switch off — a talking tutor silenced by a hardware switch
would look like a broken feature.
Kept on SDK 54 deliberately. The dev build no longer needs Expo Go, but Faith
does until a signed build can reach her phone, which needs the Apple Developer
account. Upgrading to 57 is the right move once that exists, not before.
ios/ stays gitignored: it is generated by prebuild from app.json, so there is no
custom native code to preserve and no Xcode merge conflicts to invite.
Verified: BUILD SUCCEEDED, installed and launched on the iPhone 17 Pro Max
simulator, welcome screen renders, tsc clean.
Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
"Did the new lessons reach production?" had no cheap answer. The manifest that carries the content stamp needs a Firebase bearer token, so verifying today's curriculum deploy came down to comparing a Cloud Run revision timestamp against a git commit timestamp: revision 00035 was created three minutes after the commit, therefore it probably contains it. That proves when a container was built, not what is inside it, and it is the kind of reasoning that is right until the once it is not. `/health` is already unauthenticated and already uptime-checked every 60s, so the stamp goes there. One curl now answers the question, and comparing it against curriculum.json is the whole check. The stamp is a hash of published lesson content, so exposing it discloses nothing the app does not already hand to every signed-in learner. Two tests, and the second is the one that matters: an empty stamp means the corpus failed to load, and a service answering "status":"ok" while serving no lessons is worse than one that fails outright. 358 backend tests. Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
…b too
The pricing page sells three things to Max on the web: a verified build record,
career coaching built on that record, and early access to Ohmlet Labs. The web
had none of them. `/v1/me/career`, `/v1/me/labs` and `/v1/curriculum/films` had
exactly one caller each and it was the phone, so the web has been charging
$34.99 for a build record it never showed.
That is the mirror of this morning's mobile defect, and finding the first is what
made me go and count the second. Both were the same shape: a feature complete on
the server, complete on one client, and sold on both.
WHAT THE WEB GAINS. The build record, with the figure nobody else can produce
sitting at the top: minutes with the camera genuinely open on a real bench. Then
Labs, with the "still rough" note kept prominent on every entry, because early
access to an unfinished feature is only a privilege if you are told which part is
unfinished. Then the 43 lesson films, which were mobile-only, with signed URLs
fetched at press-play and never cached: a cached signed URL is one that outlives
the reason it was short-lived.
Coach mode now works on the web live spine. Same camera, microphone and socket,
different persona, primed by the SERVER from its own records rather than from
anything the client sends. That is the whole point of the feature: every hardware
CV claims bench experience, and this is the one that was watched.
The web live hook also stops interpolating the mode into the URL template, so
`/ws/{}/{}` stays greppable for check-api-coverage.mjs. The mobile hook was fixed
the same way in August for the same reason.
Two things this screen refuses to do, on both surfaces now. It never flatters:
ten minutes reads as ten minutes, and there is no adjective anywhere on it,
because "solid hands-on experience" is a claim the learner would have to defend
in an interview and could not. And the caveat that every figure is a FLOOR is
shown rather than buried, because someone about to put it in front of an employer
needs that as prominently as they need the number.
Measured after, not assumed: every Max endpoint now has a caller on both
surfaces, and both personas connect from both.
All frontend guards, typecheck, web build.
Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
… on two surfaces check-api-coverage.mjs asks whether every backend route has a client calling it. That question was answered yes for `/v1/me/career`, `/v1/me/labs` and `/v1/curriculum/films` for weeks, and the caller was the phone in all three cases, while the web pricing page sold all three. The same day, in the other direction, Interview Mode existed only on the web while `mode: 'interview'` sat unused in the mobile hook. A caller anywhere is not a caller everywhere the feature is sold. BOTH_SURFACES names the five routes a paying learner is promised regardless of which surface they open, each with the reason it belongs there. The check fails unless the callers include at least one file under frontend/ AND one under mobile/. A route that stops existing fails it too, so the list decays with the code rather than outliving it. The list is deliberately not "every route". A feature that is genuinely one-surface stays out of it, with the reason written down rather than left as an absence somebody has to infer. Verified by hiding the web callers and confirming it reports exactly the state the repo was in this morning, naming both routes and the file the single caller lives in. Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
AGENTS.md told future work to run `npm run build` and never mentioned `npm run check`. The build was green through every defect found today: a report view rendering an object as a JSX child, three Max features sold on the web and callable only from the phone, and a curriculum step that shipped stale because a fix landed after the export. The guards are the half that catches those. Also states the expectation that a new guard proves it has teeth by failing on the defect before it is fixed, which is what made today's two new ones worth adding rather than merely present. Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
…speak Each stat sat in a bordered white pill with its name captioned under the number. Between the border, the padding and the word XP there was no room for the artwork to be anything but small, so the painted icons that were commissioned to carry the brand were drawn at 22pt and read as decoration beside the text. The boxes and captions are gone. The icons go to 32pt on the phone and 36px on the web, which is what removing the containers bought, and the number sits beside each one at 20pt. The icon already says which stat it is: a flame is a streak, a heart is a heart, and "XP" under a coin with resistor bands across it was telling nobody anything they had not already worked out. TWO THINGS THE CONTAINER WAS CARRYING, MOVED RATHER THAN DROPPED. The goal met its target by turning the whole pill green; now the number turns green and its icon stops being dimmed. Hearts ran empty by turning the pill grey; now the icon dims and the count becomes the countdown. Both were always duplicated in the content, which is why the box could go. THE CAPTION WAS CARRYING ONE REAL THING, AND IT WAS NOT VISUAL. A screen reader that reads "2070" and moves on has not read a stat. Every stat now carries an explicit accessibilityLabel, which only hearts had before. And the labels had to be made to WORK, which is the part that would have been easy to get wrong and call done. A View is not an accessibility element in React Native: without `accessible`, VoiceOver walks straight past the label and reads the child Text instead. The two hearts Views already had that bug and it did not matter while the caption was on screen. From today it would have. All four are now marked `accessible`, so the labels are read rather than merely present. Done on both surfaces on purpose. The web strip had the same boxes and the same captions, and letting the phone and the web diverge on the first thing a learner sees every morning is how a product stops looking like one product. The countdown stays small: formatWait can return "14m 30s", which will not sit beside a 32pt icon in a quarter of a phone bar at 20pt. Mobile typecheck and all 34 guards, all frontend guards, web build. Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
Making the number 20pt instead of 14 quietly changed what fits. The arithmetic: on a 430pt phone the strip has 398pt inside its padding, four stats share it evenly at about 99pt each, and the icon and its gap take 37 of those. That leaves ~62pt, and a tabular digit of Nunito Black at 20pt is about 12pt wide. Five digits fit. Six do not, and 100,000 XP is a number a committed learner reaches rather than a hypothetical. Inside the old box at 14pt this was not a problem, which is the point worth recording: a purely cosmetic change stopped being purely cosmetic the moment the type got bigger, and nothing would have told us until a real learner's XP crossed five digits and their strip started overlapping. 10.0K through 99.9K, then 100K, then 1.0M. Checked across the range rather than assumed: every output is five characters or fewer, from 0 to 150M. The first version got 999,999 wrong. It is under a million, so it took the K branch, and rounding 999.999 gave "1000K", which is not a thing anyone writes. It promotes on the ROUNDED value now, so 999,499 is 999K and 999,500 is 1.0M. The accessibility label keeps the full number. "12.4K XP" read aloud is a rounding, and a screen reader should not be told a different number from the one the learner actually has. Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
…s lying about which exist FAITH ASKED WHERE THE VIDEOS PLAY. Answering it turned up a defect first. Three places decided independently which skills have a film, and all three decided it the same way: not a review and not a gateway means it has one. films.py, mobile's Labs screen and the web's LabsView each carried their own copy of that guess. It was true on the day the films were rendered, and false the moment six skills were authored on 2026-08-28. The index claimed 49 films. The bucket holds 43. Labs drew a play button on all six of the new skills, and pressing it signed a V4 URL for an object that is not there. An inference about the contents of a bucket is a guess. Now it asks: sync-films.mjs lists the bucket and writes content/films.json plus its generated TS twin, curriculum.ts stamps `hasFilm` on every skill from that, the export carries it to the backend, and everybody reads it. A skill counts as filmed only when both renders, a poster AND captions are present, because a half-uploaded film is the same defect in a smaller costume. check-films.mjs enforces all of it and, deliberately, does not touch GCS: CI has no credentials for the bucket and a check that cannot run is a check that gets deleted. It fails if any of the three files goes back to deciding for itself, and it prints the unfilmed skills every run, because a silent gap is how six of them went unnoticed. THE PATH NODE, WHICH IS WHAT WAS ACTUALLY ASKED FOR. The film now sits on the path as its own full-width node, the way the checkpoint does. It goes AFTER the checkpoint, not before the skill, which is not a layout preference: it is the decision recorded on 2026-08-28. Before practice, motion is decoration. After it, motion is what makes a dozen discrete facts cohere into one picture. A learner who has answered eight questions about complete loops has the parts, and watching the current stop when the wire is cut is what assembles them. So it unlocks when the skill is cleared, which the existing sequential rule already gives for free. Deliberately a different animal from the checkpoint it follows. The checkpoint is a white card with a chest and an XP figure and it looks like a reward. This is ink and glass with a scope screen on it, a trace across the face and a play triangle, drawn in the same SVG vocabulary as the resistors on the wire. Two nodes that differ only in their words are two nodes a learner stops reading. If painted artwork arrives to sit beside the chest, it swaps for an Image and nothing else changes. The locked card is cream rather than the ink card drained, so an unwatchable film reads as somewhere you have not got to rather than a broken version of the watchable one. Its subtitle needed its own colour: white at 55% on cream is an invisible line of text saying how to unlock the thing, which is the one sentence that has to be readable when it is locked. The web path groups by skill and has no checkpoint node to hang it on, so the film lives in the skill's header there, under the same rule. Same behaviour, each layout's own shape, rather than inventing a checkpoint the web path does not have. FilmModal moved out of LabsView so both surfaces open the same player. 43 skills have a film. Six do not, and no surface offers one for them. 358 backend tests including two new ones, all frontend guards, web and backend still agree on every lesson, mobile typecheck and guards, web build. Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
…ing that is actually Faith's
The 3D twin was listed as blocked on two things: a Stability AI key and a storage
bucket. Only one of those needed Faith. gs://ohmlet-app-twins now exists in
europe-west1 with uniform access and public access prevention enforced, and the
reporter's service account has object access on it.
Public access prevention is the point rather than a default: a twin is streamed
through the authenticated /v1/twins/{id}/model endpoint after an ownership check,
so it is as private as the rest of a learner's data.
ops/enable-twins.sh does everything else in one step once the key exists: stores
it, grants access scoped to that ONE secret rather than the project, deploys the
reporter and curls /health to prove it. The key never appears in an argument,
because $1 lands in shell history and in ps output for every user on the box; it
is read from a hidden prompt or a file you then delete.
The bucket is recorded in ops/observability.md. A bucket nobody remembers
creating is a bucket nobody remembers to bill for.
Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
…duction for weeks Faith said go, so the curriculum is now evenly filmed: 43 skills to 49, every non-review skill in all twelve units. rtos-basics, volatile-and-isrs, clock-domains, can-bus, sensor-fusion and brushless-and-foc. 202 narrated segments, 3m 11s to 3m 41s each, all inside the five minute rule. Each was written around the thing a quiz genuinely cannot do: a task stopping mid-instruction because something more urgent became Ready; four bytes of a counter going across one at a time while an interrupt lands in the middle; an output sitting at an undefined level between the rails; two nodes transmitting at the same instant and one of them silently discovering it lost. TWO FILMS HAD BEEN DEAD IN PRODUCTION AND NOTHING SAID SO. Found only because fixing the index made me compare the bucket against what the server actually signs rather than against what folders exist. films.py signs v1/<skill>/ohmlet-lesson-<skill>-<shape>.mp4. Two folders had been renamed by hand from the film id to the skill id and the FILES inside them had not, so circuits-current held ohmlet-lesson-closed-loop-*.mp4 and rc-charging held ohmlet-lesson-time-constant-*.mp4. Both 404ed while all 41 others worked. circuits-current is the first film in Foundations, which made it the one a new learner was most likely to press. Repaired in the bucket, both scripts renamed so id, skillId and file name are one string, upload.sh now publishes by skill id read from the script itself, and check-films.mjs refuses the mismatch. sync-films.mjs stopped counting extensions and now requires the five exact object names the server signs, which is the check that would have caught this in the first place. A CORRECTION TO MY OWN WORK, CAUGHT BEFORE IT RENDERED. I had used the `plot` scene for MTBF against resolution time and for gyro drift. That scene is specifically the RC charging curve: its y axis is labelled V and its x axis is in tau. It would have put a voltage axis on a chart about neither, and gyro drift is a linear ramp rather than an exponential rise, so the shape would have been wrong as well as the labels. Both replaced with comparisons that say the same thing honestly. Reading the scene vocabulary rather than assuming it was generic is what caught it. Verified after upload, not before: all 49 films resolve at the exact five object names the server asks for. 0 would 404. 360 backend tests, all frontend guards, web build, mobile typecheck and guards. Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
… icons grow Faith asked for the treatment the top strip got: take the words away and let the icons be big enough to speak. Six tabs, no captions, icons from 30pt to 34. 34 AND NOT MORE, AND THE LIMIT IS MEASURED RATHER THAN CHOSEN. The painted set is authored at 30pt, so its @3x files are 90x90, and the ink already fills 90% of that canvas: there is no padding to reclaim. 34pt asks a 3x screen for 102px and gets 90, a 13% upscale that soft edges absorb. 40 would ask for 120 and start blurring the linework, which is the opposite of what making them bigger was for. Past 34 the art has to be re-exported, not stretched, and that is on the list. The caption was carrying one thing and it was not visual: the tab's name for anyone who cannot see the picture. That moved to accessibilityLabel on the Pressable, which unlike a plain View is already an accessibility element, so the label is read rather than merely present. The stat strip needed `accessible` added explicitly for exactly this reason; a touchable does not. A PLANS TAB, because the one screen that takes money was reachable only from whatever happened to prompt you. Hidden in child mode for the same reason community is, and it is the more important of the two: a minor cannot self-purchase, so a doorway to a paywall should not exist for them. The plans screen still refuses a minor on arrival, because hiding a tab is a courtesy and not a control. It is wrapped in AppTabs now so the bar stays under it, and its back link is gone: a tab has no back, it has five siblings one tap away. The web nav gained the same destination. It could only reach pricing from inside an upgrade prompt, so the page that takes money was the page with no way in. THE ICON IS DRAWN, AND ONLY UNTIL FAITH'S ARRIVES. She is making the real one and asked for a placeholder, so this is the best version of one rather than a grey box: a battery, built to the painted set's rules, off state ink linework with no plate and on state carrying its own plate so the two never change scale when you tap between them. A battery rather than a coin or a crown because every other icon in this bar carries an electronics idea, and a coin says "payment" in a way that belongs to any app while a battery says how much you have got, which is what a plan is here. Three cells for three tiers. The risk with a temporary thing is that it stops being temporary, so the swap is enforced: check-stat-icons.mjs fails the moment plans-*.png exist and the SVG is still being drawn, fails on a half-present density ladder, and fails if the tab ends up with neither. Verified by dropping stand-in art in and watching it fail before taking it out again. 360 backend tests, all frontend and mobile guards, web build. Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
…older is gone The SVG battery lasted one day, which is the right lifetime for a placeholder. The source is a 1254px mascot with a tool belt, so it needed preparing rather than dropping in: trimmed to its alpha bounding box, squared so the aspect survives a square slot, and rendered at 86% of the tile so it does not touch the plate's edge, the way the painted set is framed. THE PLATE WAS MEASURED, NOT GUESSED, AND THE FIRST ATTEMPT WAS WRONG. A mascot has no plate of its own, so the selected state needs one composited in. I drew a gold-bordered rounded square from the design tokens, rendered the whole row of six side by side at true 3x size, and it was obviously wrong: every other icon's plate is a flat fill with NO outline. Sampling community-on@3x gave the real numbers, 9px inset and a corner curve running y=9 to 25 on a 90px canvas, so 10% and 18%, on a flat #fff6d6. Redrawn to those, it sits in the row as a sibling. Rendering the row was the whole trick. Reading my own code would never have shown me the border was too heavy; putting the six icons next to each other at the size a phone actually draws them showed it in a second. It is the only full-colour icon in the bar in BOTH states, and that is left deliberately. The other five are ink linework until selected, so the one tab that asks for money is the one that catches the eye, which is what Duolingo does with Super and is presumably why a mascot was drawn for it rather than a glyph. Checked at true size before shipping: at 34pt on a 3x screen the silhouette, the face, the red crest and the omega on the pouch all read. The plug and the LED in the belt become specks. That is the cost of a detailed mark at tab size and it is worth naming rather than discovering later. `plans` now joins TABS in check-stat-icons.mjs, so it is held to the same density ladder as the other five, and a separate check refuses the drawn placeholder if it ever comes back. Verified by removing plans-off@2x.png and watching it fail. All mobile and frontend guards, typecheck, web build. Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
…there all along I said the icons could not go past 34 without redrawing them. That was wrong, and it took one find to disprove: frontend/assets-source/nav holds all ten at 512x512. The shipped set was 30/60/90, so 34pt was already asking a 3x screen for 102 pixels and getting 90. Nothing had to be redrawn. It had to be cut again. 40pt now, authored for it: 40/80/120. The bar grows by 2pt rather than 6, because the padding came down as the icons went up. WHAT THE SCRIPT PRESERVES, AND WHY IT IS NOT THE OBVIOUS THING. Scaling the 512 canvas straight down would have been the one-liner, and it would have made every selected icon about 6% smaller than it is today. Measured first: the ten shipped icons sit between 58% and 100% of their canvas, and those numbers are not a formula. They are somebody normalising the set on its LINEWORK so that an `off` state and its `on` state carry the same visual weight, which is the thing that stops the bar twitching when you tap between them. So each icon is trimmed to its ink, rescaled to the ink fraction it already had, and re-centred where it already sat. Verified after: learn 96%, practice 100%, live 90%, community 88%, the same as before. The set looks identical. It is simply no longer the limit, and the script takes a size argument, so the next time this comes up it is one command rather than an investigation. Faith's mascot source is saved beside the others as plans-source.png rather than left in a temp directory, so the next export has all six. Rendered the whole row at true 3x before and after rather than trusting the arithmetic. The linework is visibly cleaner: the resistor bands on the practice icon separate, and the mascot's tool belt holds together where it was starting to smear. All mobile and frontend guards, typecheck, web build. Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
…e artwork The previous icon had one drawing doing both jobs: full colour whether selected or not, on a plate I composited myself. It worked, but it made the plans tab the only one that never went quiet, and the plate was mine rather than the artist's. Faith sent both states in one sheet, ink linework on the left and full colour on its own plate on the right, which is exactly the shape the painted set uses. So this splits rather than composites, and the plate-drawing code is gone. MY FIRST CUT MADE IT THE SMALL ONE, AND ONLY THE RENDERED ROW SHOWED IT. I set the plate at 80% of the tile from a 10% inset I had measured off the OLD 90px files. Putting the six side by side at true 3x, the plans plate was visibly smaller than its neighbours. Measuring the set again at 120px gave the real numbers: off states run 62% to 75% of the tile, on states 92% to 100%. Recut at 69% and 95%, it sits in the row as a sibling. That is twice now that rendering the row has caught something reading the code never would: first a plate border that should not have existed, now a plate a sixth too small. It is three lines of PIL and it should happen every time this artwork changes. The off state is the actual win. The other five are ink until you select them, so the bar had one tab permanently shouting. Now it is quiet like the rest and lights up when you are on it, which is what a selected state is for. Source saved as plans-states.png beside the other ten, and export-nav-icons.py splits it, so re-exporting at another size stays one command. All mobile guards, typecheck. Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
mobile/.gitignore already ignored *.p8 and *.pem. The root did not, and the root is where a file downloaded from App Store Connect actually lands. Prompted by today's in-app purchase key. Nothing was committed, and nothing key-shaped is tracked, but the gap was real and one careless `git add` wide. *.p8, *.pem, *.p12 and *.mobileprovision now refused at the root as well. Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
The endpoint has existed since Interview Mode shipped and the secret was never deployed, so OHMLET_REVENUECAT_WEBHOOK_SECRET was empty in production. Checked what that actually meant rather than assuming the worst: the handler refuses with 503 when the secret is absent, exactly as the Stripe one does, so an unset secret is a dead endpoint and never an open one. hmac.compare_digest on two empty strings returns True, which is why that guard has to exist and why it was worth reading before raising an alarm. The consequence was still real, just quieter: a purchase would have charged the card and granted nothing, because the only writer of a paid plan on iOS would have been returning 503 to every retry. Generated with secrets.token_urlsafe and piped straight into Secret Manager, so it never entered a shell history or a chat. Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
RevenueCat stamps every webhook event with an environment, SANDBOX or PRODUCTION. The handler ignored the field completely. Sandbox purchases cost nothing: Apple does not charge a sandbox tester. So anybody who could make one had a route to a genuine Max plan, written into production Firestore, permanently, alongside plans people had actually paid for and indistinguishable from them. Found while answering a question about RevenueCat's "secure your sandbox access" card. That list is the other half of this and neither is sufficient alone: theirs controls who can make a sandbox purchase, this controls what one does to our data. REFUSING ALWAYS WOULD HAVE BEEN THE EASY FIX AND THE WRONG ONE. The end-to-end test would then be unverifiable: a purchase on the phone would reach RevenueCat and stop, and "the webhook fired" is not the same evidence as "the learner's plan changed". So it is an explicit switch, on now in deploy.sh with the launch-gate warning sitting directly above it, and off by default in code so a service deployed without that line is safe rather than open. An event with NO environment field is treated as production. The unknown case must not be the free one, or a missing field becomes a way in. Plans now carry an `environment` field on the document, so a sandbox grant can be found and swept instead of living on looking like revenue. 363 backend tests, three of them new: refused by default, accepted when deliberately switched on, and production when the field is absent. Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
…hat it charges Apple sells subscriptions only at fixed price points, so an annual price is never just the advertised monthly times twelve. Pro: 11.99 x 12 is 143.88, which Apple does not offer, and 143.99 which it does. Eleven cents a year, invisible on a page that says $11.99. Max: 26.99 x 12 is 323.88, and 323.99 is not offered either. Faith scrolled the whole list rather than assuming a .99 exists at every dollar the way it does lower down. The nearest is 324.99, which works out at $27.08 a month, ABOVE what the page advertised. SO THE PAGE CHANGED, NOT THE STORE. A page quoting $26.99 while Apple charges $27.08 is a page that is wrong, and the fix is not to pick a cheaper price point so the marketing number survives. Max annual now reads $27.08. The page also said "billed annually" and never once said HOW MUCH. It gave the cadence and withheld the amount, which is the half a buyer actually needs. It now reads "billed annually at $324.99", and carrying that figure explicitly is what made the Apple mismatch visible in the first place. check-prices.mjs only ever checked monthly. It now checks that each tier's annual total agrees with its advertised monthly rate, and fails in the direction that matters: quoting less than you charge. Verified by setting Pro's total to 159.99 and watching it refuse. Both annuals remain a real discount on paying monthly: 25% on Pro, 23% on Max. All frontend guards, typecheck, web build. Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
… hiding
Faith went to screenshot the plans tab for Apple's review requirement and found
it only shows monthly. Four products exist in App Store Connect and half of them
had nowhere to be bought.
THE TOGGLE WAS THE SMALLER HALF. packageFor matched a package to a tier by
looking for the tier name and nothing else:
packages.find((pkg) => `${pkg.id} ${pkg.title}`.includes(plan))
`pro` matches BOTH pro_monthly and pro_annual. With one product per tier that
worked. With four it returns whichever Apple happens to list first, so the Pro
card could show $15.99 above a button that charges $143.99. Harmless yesterday, a
live mischarge the moment the offering imports, and invisible until then because
there was nothing to be ambiguous about.
It now matches tier AND interval.
The toggle defaults to Annual: it is the better value and the one a learner is
least likely to go looking for, which is how the web page already behaves.
An annual package's priceString is the YEARLY total, so it is divided back to sit
under the "/month" label, and the real total is printed underneath. The web
gained the same line this morning for the same reason: "billed annually" alone
gives the cadence and withholds the amount, which is the half somebody needs.
check-prices.mjs compared MONTHLY across three sources. The phone now publishes
annual prices too, so two files can disagree about them, which is precisely what
this script exists to stop. It now compares both annual figures between the page
and the phone. Verified by setting the phone's Max total to 319.99 and watching
it name both sides of the disagreement.
All frontend and mobile guards, typecheck, lint, web build.
Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
The webhook mapped `pro` and `max`. RevenueCat's onboarding wizard names entitlements after the app, so Ohmlet's are `ohmlet_pro` and `ohmlet_max`, and RevenueCat does not allow the ones it creates to be deleted. A purchase would have charged the card, RevenueCat would have recorded the subscription, and _plan_from_entitlements would have returned free because it recognised neither name. That is the worst shape a billing bug can take: everything downstream looks healthy and the learner sits on 60 minutes having paid for 240. I had written the opposite instruction into the runbook, that the identifier has to be `pro`. Fixing the map is one line. Insisting on the names I would have preferred, over the names that exist and cannot be deleted, would have been a purist position paid for by a customer. Highest tier still wins when both are held, so this cannot silently downgrade. 365 backend tests, two new: the wizard's names grant the right plan, and holding both keeps the higher. Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
…ested
It shipped with a bug that nothing in the repo could have caught, because pure
logic that decides which product to charge for was living inside a React screen.
packages.find((pkg) => `${pkg.id} ${pkg.title}`.includes(plan))
`pro` matches BOTH pro_monthly and pro_annual. Correct while there was one
product per tier; a mischarge the moment four existed this morning. The Pro card
would take whichever package Apple listed first and could show $15.99 above a
button that takes $143.99. Nothing would fail, nothing would log, and the only
symptom would have been somebody's bank statement.
Now in services/packageMatch.ts, with scripts/check-package-match.mjs running it
against the four identifiers actually configured in RevenueCat. The guard reads
the matcher out of the module rather than restating it: a check that reimplements
the logic it guards proves only that it can copy.
Three things it holds:
every tier finds its own package at both intervals, and not its sibling
the wizard's undeletable Test Store leftovers, `monthly` and `yearly`, name no
tier and must never be sold as one
an empty or absent offering returns null so the screen can say why, rather
than guessing
Verified by reverting the matcher to the shipped version and watching it name
both mismatches and the consequence.
The package identifiers are a contract between a dashboard nobody diffs and a
screen that takes money, so if the offering is ever rebuilt, EXPECTED in the
guard changes with it.
Mobile guards and typecheck, frontend guards, 365 backend tests.
Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
…t lied about who granted it Two defects the first real sandbox purchase exposed, neither of which was the thing being tested. RevenueCat's `configure()` is one-shot and `initBilling` memoised its state, so whichever of four call sites landed first decided the identity permanently. Three pass no uid; the plans screen passes `user?.uid`, which is undefined on the first render before auth resolves. Lose that race and RevenueCat invents a `$RCAnonymousID:` that nothing can change, the webhook refuses the event as unattributable, and a learner who has paid sits on the free tier's 60 minutes with no error anywhere they can see. Fixed twice over, deliberately. A uid arriving after configure now attaches via `logIn()`, which aliases any purchase made in that window; and `purchasePackage` refuses outright while the id is anonymous. Blocking a purchase is recoverable, swallowing one is not. `setBy` was separately wrong. `set_plan` merges, so a field nobody rewrites outlives the truth: a document read `setBy: admin-console` from a manual edit five days earlier while RevenueCat was granting Max. Every write now names its source, and the tests record the provenance rather than tolerating the kwarg. The sandbox-billing switch is off by default. It was a hardcoded `=true` above a comment saying REMOVE BEFORE LAUNCH, which is a note asking a human to remember on the day they are busiest, not a gate. It is opt-in for one invocation now and prints in red when thrown. Guards: check-billing-identity.mjs and test_deploy_posture.py, each proven against the defect it exists for.
…ame back Reported: "I had a 2-day streak, then I went a full day without doing a lesson, but the 2-day streak still persists," and later the same of the daily goal. The write rule was already right. `applyCompletion` resets when the last active day is neither today nor yesterday. But it can only run ON a completion, and on the day a streak dies there is by definition no completion to run it, so nothing ever wrote the break. Every surface therefore showed the number last stored, and it self-corrected the next time the learner finished something: far too late, and DOWNWARD at the exact moment they had come back and done the work. The app rewarded a return by taking the streak away in front of them. The daily goal is worse in one respect. A stale streak flatters the learner; a stale goal DELETES the thing they were meant to do today, because the ring is already full and there is nothing left to close. So there is a read rule beside the write rule now: `currentStreak`, `streakAtRisk`, `completedTodayNow`, `todayLessonIdsNow`. Note the asymmetry they encode. Yesterday keeps a STREAK alive, because the learner has until midnight; yesterday does nothing for a GOAL, which is the work of one calendar day. achievements.py gained the same rule in Python. It stamps medals permanently, so reading a stale streak there could mint a seven-day medal for someone the app was already showing as broken, and never take it back. The web guarded the goal inline and the phone did not, which is exactly the split the shared rule exists to prevent, and exactly how the streak bug survived. The guard now runs both TypeScript copies and the Python one over a single table.
… user who does not exist Pro $15.99/$143.99 -> $12.99/$77.99, 240 -> 150 min. Max $34.99/$324.99 -> $24.99/$149.99, 540 -> 300 min. Both annuals are 50% off monthly rather than 25%. Ireland now sees EUR 184.99 for Max annual instead of EUR 399.99. Faith's instinct was that $324.99 was too high. It was, but not for the reason she gave and not to the number she proposed. It was too high because it had DRIFTED. metadata/strategy/2026-07-02 set Max at "about 29.99/mo or 239.88/yr"; live was $34.99/$324.99, 35% above a target that had analysis behind it, and nobody decided that gap. Duolingo was the wrong yardstick and was anchoring us low: their marginal cost is fractions of a cent, ours is $0.053 every minute. And $210/yr does not work at all, because after Apple's cut it affords 231 minutes at the 30% rate, which is fewer than Pro's 240. The tier collapses. Two findings decided the shape: Annual was ALREADY underwater at full cap and had been since it existed, because the break-even arithmetic only ever tested the MONTHLY price. The plan we discount people onto was the loss-making one. And pricing so a 100%-of-cap user is profitable was double-insurance, because the hard minute cap already bounds the worst case. We now price for expected utilisation and let the cap absorb the tail. What makes the deep discount safe: MONTHLY clears full cap on both tiers, +$3.09 and +$5.34, breaking even at 134-139% of a cap the server enforces. Most early subscribers take monthly, so the monthly cohort funds the learning while the $0.053/min figure is still unmeasured. check-prices.mjs gained two sections: the discount badge is checked against the actual arithmetic, overstating failing harder than understating because one is a false claim on a paywall; and every "N hours" claim on every surface must equal the real minute cap. That second one caught UpgradeSuccess still selling 9 hours on the post-checkout receipt.
…t leave Reported: "if you press Live Tutor and then you press back, it takes you back to the login screen... if you slide to the left, it takes you straight back to the login page." The tab bar navigates with `router.replace`, so a tab root is the only entry in the stack with nothing behind it. But iOS enables the interactive edge-swipe on every stack screen by default, and the gesture still fired: it unwound PAST the tab root to the entry gate in index.tsx, which re-runs auth, re-reads the age gate and shows the brand splash for 1.4 seconds. To a learner that is the app throwing them out mid-session. `plans` was also missing from the layout list entirely, which is how it acquired both the wrong animation and the gesture the moment it was added. Then the fix created a worse trap. `live.tsx` was the only one of six tab roots that never rendered AppTabs; its single exit was a "‹ Back" link that always returns to Learn. The broken swipe had been quietly serving as the second way out, so disabling it closed the trapdoor and left the room with one door. And then the segmented control added two MORE paths with no bar, hours later, in the same file. The guard stayed green because it only asked whether the file MENTIONED AppTabs. It now walks each tab root and checks what every `return (` actually opens with; a screen root that is not AppTabs must be declared deliberate, and only the live session is, because there the camera owns the screen and End is a real exit. That stricter rule immediately found four more: the bar vanished while Community loaded, on its offline state, and on both child-mode refusal screens. Navigation was being taken away at the moment the learner most wants it. The rule is simply that the bar never disappears. Separately, a full-screen Modal is a SEPARATE native view hierarchy presented above the root shell, so it inherits none of that shell's paddingTop: insets.top. FilmPlayer stood in with a fixed 32 against the 59 a Dynamic Island reserves, and the close button sat under the island. Same defect on the twins camera bar against the home indicator. check-modal-insets.mjs fails any full-screen modal whose content does not read insets. Also here: Bench, Interview and Coaching as a segmented control on the Live tab, so Interview Mode and Career Coaching finally have a home in the navigation rather than living on a settings page.
Light, Dark and System, where System follows the device live rather than on next launch. Scoped to the WORKSPACE on the web: the landing pages are the marketing face and a dark landing page is a different product, not a preference. The two surfaces were very different problems. The web palette became RGB CHANNEL variables bound into Tailwind as `rgb(var(--x-rgb) / <alpha-value>)`, which themed ~1,700 class references with no component edits. The channel form is not stylistic: 83 places write `text-ohmlet-ink/70`, and Tailwind cannot compose an opacity modifier over an opaque colour, so a plain var() would have silently rendered every one at full opacity. That was the cheap half. 439 raw Tailwind colours across 46 files bypassed the palette entirely, because a raw colour is immune to any amount of token architecture. 234 bg-white, 113 text-white, 55 slate/gray, and 42 pale tint washes written as arbitrary values like bg-[#fdece8], which is why the Live Tutor card kept a pale pink ground and swallowed the text on it. text-white could not be swept. On gold or blue it is right in both themes; on bg-ohmlet-ink it is a bug, because ink inverts to near-white and 23 places would have rendered WHITE ON WHITE. Mobile could not be done with tokens at all. 69 StyleSheet.create calls sit at module scope, evaluated once at import, capturing whatever colour was current. They can never react to a theme however correct the palette is. A makeStyles factory replaced them across 76 files. Three defects that predate dark mode and were only visible once it existed: bg-ohmlet-slate-900 is not a token. TwinStudio and InterviewView have been shipping with NO background. A class that looks like a token survives review precisely because it looks right. UIUserInterfaceStyle was Light in ios/Ohmlet/Info.plist while app.json said automatic. iOS obeys the plist, so it forced the app to light and Appearance.getColorScheme() returned light for ever: System reported "following your phone, which is set to light" on a phone set to dark. ios/ is a committed native project, so app.json never reaches the plist without a prebuild. Two sources of truth, and the one that ships is the one nobody edits. The Built tile counted SESSIONS while the shelf beside it counted authored lessons. 142 lessons ship as 284 sessions, so one screen carried two denominators for the same thing. Vocabulary the work forced into existence, because one word was doing two jobs: `surface` (a raised card), `slab` (dark in BOTH themes), `onInk` (inverts, for the ink fill), `onSlab` (does NOT invert, for a permanently dark chip), `onGold`, `plate` (the press shadow, invisible on dark as ink). The sandbox ruler labels found onSlab: their chip is a fixed dark wash and their text was `cream`, so the F1 / A2 / GND names went dark on dark off a breadboard that is white in both themes. The logo split into mascot art plus a text wordmark. The combined PNG bakes the lettering in as dark ink, so the name vanished and the sidebar showed a floating mascot. A second PNG would be two assets to keep in sync for ever. Nav icons got dark variants generated by flipping HSV value and leaving hue and alpha alone: ink becomes a light stroke, fills recede, silhouette identical. A tintColor was tried and rejected, because it recolours every opaque pixel and the icons that survive do so precisely because they are filled. Profile was rebuilt: real achievement CARD art rather than generated glyphs, the existing painted XP and streak icons, two new painted glyphs for Built and Live minutes, three structurally different card types, and the module-scope snapshot that Learn and Community use, because router.replace unmounts a tab and Profile was repainting "0 XP, 0 streak" at a learner with a month of history. Two check-theme guards, one per surface, each proven against its defect. The web one caught a hardcoded colour in my own logo change mid-flight. It also records its own limit rather than hiding it: it reads one line at a time and cannot see white text whose dark ground is set on a parent, so three categories are listed deliberately, each with the judgement it encodes written down.
Bumps [uvicorn](https://github.com/Kludex/uvicorn) from 0.35.0 to 0.52.4. - [Release notes](https://github.com/Kludex/uvicorn/releases) - [Changelog](https://github.com/Kludex/uvicorn/blob/main/docs/release-notes.md) - [Commits](Kludex/uvicorn@0.35.0...0.52.4) --- updated-dependencies: - dependency-name: uvicorn dependency-version: 0.52.4 dependency-type: direct:production ... Signed-off-by: dependabot[bot] <support@github.com>
dependabot
Bot
force-pushed
the
dependabot/pip/backend/quiz-engine/uvicorn-gte-0.52.4
branch
from
September 1, 2026 13:36
1ca1eb8 to
867d396
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Bumps uvicorn from 0.35.0 to 0.52.4.
Release notes
Sourced from uvicorn's releases.
... (truncated)
Changelog
Sourced from uvicorn's changelog.
... (truncated)
Commits
8988c23Stabilize macOS Python 3.13 signal shutdown tests (#3084)898ddcaUpdate PyPI publish action to version 1.14.2 (#3083)3869f8aRestore Mermaid diagram rendering (#3080)64148a9Version 0.52.4 (#3079)9e9e569docs: correct release example PR number (#3072)b783dacRemove duplicate Date header from SansIO WebSocket handshakes (#3078)27019b2chore(deps): bump the python-packages group across 1 directory with 11 update...1b64273Fix a typo in index.md (#3006)a68da60Version 0.52.3 (#3068)6e3bb4cUse zttp 0.0.24 fast receive path (#3067)