subjects + topics tables. Every tile clickable, every route real. Search bar functional. Click anything → /setup?topic=X (which 404s until P3 lights up).Five user-facing routes go live: / (discovery) · /physics · /math · /history · /english. Each renders from a Jinja2 template against the seeded subjects + topics tables. The search bar accepts free-form text and routes to /setup?topic=<encoded>. Every clickable tile (subject card · topic chip · curiosity question · recent row · trending pop) is a real <a> with the right query string. The setup route returns 404 — P3 lights it up.
Acceptance demo: visit https://omnitutor.ai/ · see the discovery page rendered against real DB · click "Physics" tile · land on /physics · click "Classical mechanics" topic · land on /setup?topic=classical-mechanics&subject=physics&source=tile · 404 page is friendly and explains "P3 not yet built."
/v1/healthz · /v1/session · /v1/hello/ yet · only _design/ static reviewsubjects + topics tables exist · emptyomnitutor.ai/ renders the discovery page/physics · /math · /history · /english render real subject pages/setup?topic=…&source=search/setup until P3Eleven ordered steps. Templates first, then routes, then queries, then events.
web/templates/_base.html with topnav · brand · sign-in stub. _partials/owl.html · _partials/topnav.html. Jinja2 inheritance. Tokens.css imported once.seeds/subjects.sql populates 4 rows (physics · math · history · english) with archetype + display name. Idempotent (ON CONFLICT DO NOTHING).seeds/topics.sql populates ~32 canonical topics (8 per subject) lifted from the 4 design mockups. canonical=TRUE. Idempotent.app/db/subjects_repo.py (list_subjects · get_subject_by_code) · app/db/topics_repo.py (list_topics_for_subject · get_or_create_topic for free-form). Pure asyncpg + pydantic.app/services/discovery.py with get_recents(user_id) (top 4 lessons by recency) · get_trending(limit=6) (topics by 7-day lesson count from events) · get_curiosity_cards() (hand-curated for M1).GET / renders discovery.html with subjects · recents · trending · curiosity_cards · tagline (locked at "Learn anything, on your terms.").GET /<code> renders subject.html (or subject_language.html for English archetype). 404 if code not in subjects table. Template gets subject · topics · libraries (for STEM) · levels.GET /search?q=<query> · POST /search from form · sanitizes input · 302 to /setup?topic=<urlencoded>&source=search.{% link_to_setup(subject_code, topic_code, source) %} · single source of truth for setup route URLs. All tiles use it.session.start if first hit) and every tile click (lesson.invoke with {subject, topic?, source}) · uses POST /v1/event.404.html with owl + lead "I haven't learned that route yet · here's home →". Used for /setup until P3.omnitutor-app/
├── app/
│ ├── routes/
│ │ ├── discovery.py # GET /
│ │ ├── subjects.py # GET /
│ │ ├── search.py # GET /search
│ │ └── errors.py # 404 handler
│ ├── services/
│ │ └── discovery.py # recents · trending · curiosity
│ ├── db/
│ │ ├── subjects_repo.py
│ │ ├── topics_repo.py
│ │ └── lessons_repo.py # for recents query
│ └── templates/
│ ├── _base.html
│ ├── _partials/{owl, topnav, footer}.html
│ ├── discovery.html
│ ├── subject.html # physics + math + history
│ ├── subject_language.html # english
│ └── 404.html
├── seeds/
│ ├── subjects.sql
│ └── topics.sql
└── web/static/
├── tokens.css
└── (existing P1 assets)
-- seeds/topics.sql · idempotent · ~32 rows INSERT INTO topics (id, subject_id, name, canonical) VALUES ('01HZS-PHY-001', 'physics', 'Classical mechanics', TRUE), ('01HZS-PHY-002', 'physics', 'Electricity & magnetism', TRUE), ('01HZS-PHY-003', 'physics', 'Quantum mechanics', TRUE), -- … 5 more physics ('01HZS-MAT-001', 'math', 'Algebra', TRUE), ('01HZS-MAT-002', 'math', 'Calculus & analysis', TRUE), -- … 6 more math ('01HZS-HIS-001', 'history', 'Antiquity', TRUE), -- … 7 more history (5 eras + 3 themes) ('01HZS-ENG-001', 'english', 'Travel English', TRUE) -- … 7 more english ON CONFLICT (id) DO NOTHING;
| Route | Method | Renders | Notes |
|---|---|---|---|
| / | GET | discovery.html | main landing |
| /physics | GET | subject.html | science archetype |
| /math | GET | subject.html | science archetype |
| /history | GET | subject.html | humanities archetype (palette tweak) |
| /english | GET | subject_language.html | language archetype (CEFR ladder) |
| /search?q=… | GET | 302 → /setup | free-form topic |
| /setup?… | GET | 404.html (M1) | P3 lights this up |
| /v1/event | POST | JSON OK | analytics ingestion |
SELECT COUNT(*) FROM subjects = 4 · SELECT COUNT(*) FROM topics WHERE canonical ≥ 30GET / returns 200 · HTML contains 4 subject tiles · search bar · "Learn anything, on your terms." headlineGET /physics · /math · /history · /english all return 200 with canonical topics from DBGET /chemistry returns 404 with friendly owl page (no canonical chem subject yet · M1 ships only the 4)_design/)/physics → URL ends with /setup?topic=<slug>&subject=physics&source=tile/setup?topic=Bohr%20atom&source=search/setup?topic=<chip-text>&source=trychipGET / · response contains the 2 lessons in recents sectionGET / · trending section shows the seeded topics in count orderPOST /v1/event with type=lesson.invoke, payload={subject,topic,source} · row visible in eventsGET /setup?topic=foo returns 404 with owl + "→ home" link · clicking link returns to /eventsP2 ships when every line below is true. P3 cannot start until then.
main/ for performance + accessibilityv1.2-p2-shipped