-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy patharchitecture.html
More file actions
1514 lines (1376 loc) · 90.6 KB
/
Copy patharchitecture.html
File metadata and controls
1514 lines (1376 loc) · 90.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AI Testing Academy — Architecture</title>
<style>
:root{
--bg:#f4f5f7; --panel:#ffffff; --sunk:#eef0f4;
--fg:#161a20; --muted:#5d6673; --line:#dde1e8;
--accent:#2f5fe0; --accent2:#8a4fd8; --ok:#1d7a4c; --warn:#a76a12; --err:#bf3126;
--code:#f1f3f7;
--shadow:0 1px 2px rgba(16,20,28,.05), 0 14px 34px -24px rgba(16,20,28,.5);
}
@media (prefers-color-scheme: dark){
:root{
--bg:#0d1015; --panel:#151a21; --sunk:#1b212a;
--fg:#e6eaf1; --muted:#97a2b1; --line:#252d38;
--accent:#7aa2ff; --accent2:#c093ff; --ok:#4fc98a; --warn:#e2b155; --err:#f2705f;
--code:#11161d;
--shadow:0 1px 2px rgba(0,0,0,.5), 0 16px 36px -24px rgba(0,0,0,.9);
}
}
*{box-sizing:border-box}
/* Guarded: a reader who has asked the OS to reduce motion should not be
thrown down a 15,000px page by a table-of-contents click. */
@media (prefers-reduced-motion:no-preference){html{scroll-behavior:smooth}}
body{
margin:0; background:var(--bg); color:var(--fg);
font:15.5px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
-webkit-font-smoothing:antialiased;
}
.wrap{max-width:1080px;margin:0 auto;padding:36px 20px 90px}
header .eyebrow{
font:600 12px/1 ui-monospace,SFMono-Regular,Menlo,monospace;
letter-spacing:.14em;text-transform:uppercase;color:var(--accent);margin:0 0 12px;
}
header h1{font-size:clamp(1.8rem,4.6vw,2.5rem);line-height:1.1;letter-spacing:-.02em;margin:0 0 10px}
header .stand{color:var(--muted);max-width:70ch;margin:0 0 18px;font-size:1.03rem}
.pill{
display:inline-block;font-size:12px;font-weight:600;padding:3px 11px;border-radius:20px;
background:var(--sunk);border:1px solid var(--line);color:var(--muted);margin:0 6px 6px 0;
}
.pill.on{color:var(--accent);border-color:color-mix(in srgb,var(--accent) 40%,var(--line))}
h2{font-size:1.32rem;letter-spacing:-.015em;margin:52px 0 10px;padding-bottom:8px;border-bottom:1px solid var(--line)}
h3{font-size:1.02rem;margin:26px 0 6px}
h4{font-size:.94rem;margin:0 0 4px}
p{margin:0 0 12px}
.lead{color:var(--muted)}
a{color:var(--accent);text-decoration:none}
a:hover{text-decoration:underline}
/* ---- High-level block diagram ------------------------------------------
A pastel palette, defined once as tokens so the light and dark themes each
get a set that sits correctly on their own background rather than one set
tinted twice. Pastels are pale by definition, so every one of them carries
dark ink in light mode and the fills darken in dark mode — the label is
what has to stay readable, not the swatch. */
.blocks{--c-client:#dbeafe;--c-client-ink:#1e3a5f;--c-client-line:#93b4e8;
--c-server:#dcf3e8;--c-server-ink:#14532d;--c-server-line:#8fcfae;
--c-shared:#f3e8ff;--c-shared-ink:#4c1d78;--c-shared-line:#c4a5e8;
--c-out:#ffe9d6;--c-out-ink:#7c3a06;--c-out-line:#f0bd8a;
--c-edge:#9aa4b2;
margin:26px 0 6px}
@media (prefers-color-scheme: dark){
.blocks{--c-client:#1c2c46;--c-client-ink:#bcd4f7;--c-client-line:#33507e;
--c-server:#163024;--c-server-ink:#a9e3c4;--c-server-line:#2c5a42;
--c-shared:#2a1f3d;--c-shared-ink:#d6bdf5;--c-shared-line:#4a3568;
--c-out:#3a2717;--c-out-ink:#f2caa2;--c-out-line:#6b4526;
--c-edge:#5b6675}
}
.blocks svg{width:100%;height:auto;display:block}
.blocks .bx{stroke-width:1.5;rx:10}
.blocks .lbl{font:600 13px ui-sans-serif,system-ui,sans-serif}
.blocks .sub{font:11px ui-monospace,SFMono-Regular,Menlo,monospace;opacity:.85}
.blocks .grp{font:700 10.5px ui-sans-serif,system-ui,sans-serif;letter-spacing:.1em;
text-transform:uppercase;fill:var(--muted)}
.blocks .edge{stroke:var(--c-edge);stroke-width:1.4;fill:none}
.blocks .edge-lbl{font:10.5px ui-sans-serif,system-ui,sans-serif;fill:var(--muted)}
.blocks figcaption{color:var(--muted);font-size:.9rem;margin-top:10px;max-width:78ch}
/* The horizontal pill row stays as the in-flow table of contents; it is the
one that survives on a narrow screen, where a fixed rail would eat the
width the prose needs. */
.toc{display:flex;flex-wrap:wrap;gap:8px;margin:22px 0 4px}
/* The rail: a fixed vertical index down the left margin, so section 11 is one
click from section 2 without scrolling back to the top. It lives in the
gutter beside .wrap (max-width 1080), so it is only shown once the viewport
is wide enough to hold both without overlapping. */
.rail{position:fixed;top:50%;transform:translateY(-50%);inset-inline-start:max(12px,calc((100vw - 1080px)/2 - 210px));
width:196px;max-height:82vh;overflow-y:auto;overscroll-behavior:contain;
padding:14px 12px;border:1px solid var(--line);border-radius:14px;
background:color-mix(in srgb,var(--panel) 92%,transparent);
-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);
box-shadow:var(--shadow);z-index:40;display:none}
.rail h2{font-size:11px;letter-spacing:.09em;text-transform:uppercase;color:var(--muted);
margin:0 0 8px;padding:0 8px;font-weight:700;border:0}
.rail a{display:flex;gap:8px;align-items:baseline;font-size:12.5px;line-height:1.35;
padding:6px 8px;border-radius:8px;color:var(--muted);text-decoration:none;
border-inline-start:2px solid transparent}
.rail a .n{font-variant-numeric:tabular-nums;opacity:.7;min-width:1.3em}
.rail a:hover{color:var(--fg);background:var(--sunk);text-decoration:none}
/* Scroll-spy. `aria-current` rather than a class, so the state is announced
and not only painted. */
.rail a[aria-current="true"]{color:var(--accent);background:var(--sunk);
border-inline-start-color:var(--accent);font-weight:600}
@media (min-width:1320px){.rail{display:block}}
.toc a{font-size:13px;padding:5px 12px;border:1px solid var(--line);border-radius:20px;background:var(--panel);color:var(--muted)}
.toc a:hover{color:var(--accent);border-color:color-mix(in srgb,var(--accent) 40%,var(--line));text-decoration:none}
.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px 18px;margin:14px 0;box-shadow:var(--shadow)}
.card.tight{padding:13px 15px}
code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.87em;background:var(--code);border:1px solid var(--line);border-radius:5px;padding:1px 5px}
pre{background:var(--code);border:1px solid var(--line);border-radius:10px;padding:14px 16px;overflow-x:auto;font-size:13px;line-height:1.55;margin:10px 0}
pre code{background:none;border:0;padding:0;font-size:inherit}
.scroll{overflow-x:auto;-webkit-overflow-scrolling:touch}
table{width:100%;border-collapse:collapse;font-size:14px;margin:8px 0;min-width:520px}
th,td{text-align:left;padding:9px 11px;border-bottom:1px solid var(--line);vertical-align:top}
th{font:600 11.5px/1.3 ui-monospace,SFMono-Regular,Menlo,monospace;text-transform:uppercase;letter-spacing:.05em;color:var(--muted)}
td code{white-space:nowrap}
/* horizontal pipeline */
.flow{display:flex;flex-wrap:wrap;align-items:stretch;gap:0;margin:14px 0}
.stage{flex:1 1 170px;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:13px 15px;box-shadow:var(--shadow)}
.stage .k{font:600 10.5px/1 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.1em;text-transform:uppercase;color:var(--accent2);display:block;margin-bottom:7px}
.stage p{margin:0;color:var(--muted);font-size:12.8px}
.arrow{align-self:center;padding:0 9px;color:var(--accent);font-size:20px;font-weight:700}
@media (max-width:760px){.flow{flex-direction:column}.arrow{transform:rotate(90deg);padding:5px 0;align-self:flex-start;margin-left:22px}}
/* nested provider stack */
.stack{margin:14px 0}
.lvl{border:1px solid var(--line);border-left:3px solid var(--accent);border-radius:10px;padding:11px 13px;background:var(--panel);margin-bottom:0}
.lvl + .lvl{margin-top:8px}
.lvl .nm{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px;font-weight:600}
.lvl .why{color:var(--muted);font-size:12.8px}
.stack .depth1{margin-left:0}
.stack .depth2{margin-left:18px}
.stack .depth3{margin-left:36px}
.stack .depth4{margin-left:54px}
.stack .depth5{margin-left:72px}
.stack .depth6{margin-left:90px}
.stack .depth7{margin-left:108px}
.stack .depth8{margin-left:126px}
@media (max-width:620px){.stack [class^=depth]{margin-left:0}}
/* two-column grids */
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:12px;margin:14px 0}
.box{border:1px solid var(--line);border-radius:11px;padding:13px 15px;background:var(--panel)}
.box .tag{font:600 10.5px/1 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.09em;text-transform:uppercase;color:var(--accent2)}
.box b{display:block;margin:6px 0 3px;font-size:14.5px}
.box span{color:var(--muted);font-size:13px}
ul.notes{margin:8px 0 12px;padding-left:20px}
ul.notes li{margin-bottom:7px}
ul.notes li::marker{color:var(--accent)}
.callout{border-left:3px solid var(--warn);background:var(--sunk);border-radius:0 10px 10px 0;padding:12px 15px;margin:14px 0;font-size:14px}
.callout b{color:var(--warn)}
.callout.good{border-left-color:var(--ok)} .callout.good b{color:var(--ok)}
.kv{display:grid;grid-template-columns:auto 1fr;gap:4px 14px;font-size:13.6px;margin:6px 0}
.kv dt{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--accent)}
.kv dd{margin:0;color:var(--muted)}
footer{margin-top:56px;padding-top:20px;border-top:1px solid var(--line);color:var(--muted);font-size:13px}
</style>
</head>
<body>
<div class="wrap">
<header>
<p class="eyebrow">Architecture · ai-testing-academy</p>
<h1>AI Testing Academy — how the whole thing is put together</h1>
<p class="stand">
A single-page React site that teaches QA automation and runs three AI-backed tools
(resume review, mock interview, question-bank enrichment) from the browser. The user brings
an API key, or an optional shared proxy in the monorepo answers for them. The same API also
exposes localized question-bank, coding-challenge, and lecture-series content from Supabase;
the current UI still renders bundled content modules. This page walks the runtime shape, the
provider abstraction, the content model, and where to extend each one.
</p>
<p>
<span class="pill on">React 19 + TypeScript</span>
<span class="pill on">Vite</span>
<span class="pill">wouter</span>
<span class="pill">Tailwind + Radix (shadcn/ui)</span>
<span class="pill">EN / HE with RTL</span>
<span class="pill">Static SPA</span>
<span class="pill">Google sign-in (identity only)</span>
</p>
</header>
<nav class="toc">
<a href="#overview">1 · What it is</a>
<a href="#runtime">2 · Runtime shape</a>
<a href="#composition">3 · Composition</a>
<a href="#ai">4 · The AI layer</a>
<a href="#content">5 · Content & i18n</a>
<a href="#features">6 · Feature modules</a>
<a href="#state">7 · State & persistence</a>
<a href="#ui">8 · UI mechanics</a>
<a href="#security">9 · Trust boundaries</a>
<a href="#build">10 · Build & deploy</a>
<a href="#testing">11 · Testing</a>
<a href="#extend">12 · Extension points</a>
</nav>
<!-- A fixed index of the same sections the pill row lists. Both are
generated from one list of headings; if a section is added, it goes in
both or the rail quietly stops matching the page. -->
<nav class="rail" aria-label="Sections">
<h2>Contents</h2>
<a href="#overview"><span class="n">1</span><span>What it is</span></a>
<a href="#runtime"><span class="n">2</span><span>Runtime shape</span></a>
<a href="#composition"><span class="n">3</span><span>Composition</span></a>
<a href="#ai"><span class="n">4</span><span>The AI layer</span></a>
<a href="#content"><span class="n">5</span><span>Content & i18n</span></a>
<a href="#features"><span class="n">6</span><span>Feature modules</span></a>
<a href="#state"><span class="n">7</span><span>State & persistence</span></a>
<a href="#ui"><span class="n">8</span><span>UI mechanics</span></a>
<a href="#security"><span class="n">9</span><span>Trust boundaries</span></a>
<a href="#build"><span class="n">10</span><span>Build & deploy</span></a>
<a href="#testing"><span class="n">11</span><span>Testing</span></a>
<a href="#extend"><span class="n">12</span><span>Extension points</span></a>
</nav>
<!-- ───────────────────────── 1 ───────────────────────── -->
<h2 id="overview">1 · What it is</h2>
<p class="lead">
One page: a launcher, then six numbered sections stacked in reading order. Three are
static teaching content; three call a language model. The API additionally exposes the
teaching content as localized, ordered data for clients that are ready to consume it.
</p>
<div class="grid">
<div class="box">
<span class="tag">static</span>
<b>⚙️ Connection Setup</b>
<span>Pick a provider and model, paste a key or lean on the server default, and test the round trip before anything else runs.</span>
</div>
<div class="box">
<span class="tag">AI · JSON</span>
<b>📄 Resume & CV Agent</b>
<span>Parses an uploaded PDF/DOCX in-browser, scores it as structured JSON, rewrites it, and exports the result back to PDF.</span>
</div>
<div class="box">
<span class="tag">static</span>
<b>🎓 Lecture Series</b>
<span>Course tracks and lecture links. The current UI reads bundled EN/HE data from <code>lib/lectures.ts</code>.</span>
</div>
<div class="box">
<span class="tag">AI · chat</span>
<b>🎙️ Mock Interview</b>
<span>A five-stage interviewer driven by one long system prompt, ending in a scored verdict on a sentinel token.</span>
</div>
<div class="box">
<span class="tag">AI · grounded</span>
<b>❓ Interview Questions</b>
<span>A curated bank, plus a “enrich with fresh questions” action that runs a web-grounded Gemini call.</span>
</div>
<div class="box">
<span class="tag">static</span>
<b>🐍 Coding Challenges</b>
<span>40 Python problems across three levels — each a prompt, then a hint, then a solution with complexity.</span>
</div>
</div>
<!-- ───────────────────────── 2 ───────────────────────── -->
<h2 id="runtime">2 · Runtime shape</h2>
<figure class="blocks">
<svg viewBox="0 0 900 380" role="img"
aria-label="The client artifacts talk to one API server, which is the only thing holding a credential and the only thing that reaches Stripe, Supabase, Google and the model vendors.">
<defs>
<marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0 0 L10 5 L0 10 z" fill="var(--c-edge)"/>
</marker>
</defs>
<text class="grp" x="16" y="20">Client · artifacts/</text>
<rect class="bx" x="16" y="30" width="176" height="58" fill="var(--c-client)" stroke="var(--c-client-line)"/>
<text class="lbl" x="30" y="54" fill="var(--c-client-ink)">AI Testing Academy</text>
<text class="sub" x="30" y="72" fill="var(--c-client-ink)">the one AI app</text>
<rect class="bx" x="16" y="98" width="176" height="58" fill="var(--c-client)" stroke="var(--c-client-line)"/>
<text class="lbl" x="30" y="122" fill="var(--c-client-ink)">10 lecture decks</text>
<text class="sub" x="30" y="140" fill="var(--c-client-ink)">1920×1080 slides</text>
<rect class="bx" x="16" y="166" width="176" height="58" fill="var(--c-client)" stroke="var(--c-client-line)"/>
<text class="lbl" x="30" y="190" fill="var(--c-client-ink)">portfolio · sandbox</text>
<text class="sub" x="30" y="208" fill="var(--c-client-ink)">static, no API</text>
<text class="grp" x="352" y="20">Server · server/</text>
<rect class="bx" x="352" y="30" width="196" height="194" fill="var(--c-server)" stroke="var(--c-server-line)"/>
<text class="lbl" x="368" y="54" fill="var(--c-server-ink)">api-server · FastAPI</text>
<text class="sub" x="368" y="78" fill="var(--c-server-ink)">/ai/generate · /ai/config</text>
<text class="sub" x="368" y="96" fill="var(--c-server-ink)">/content/*</text>
<text class="sub" x="368" y="114" fill="var(--c-server-ink)">/stripe/checkout · /prices</text>
<text class="sub" x="368" y="132" fill="var(--c-server-ink)">/stripe/seed (admin)</text>
<text class="sub" x="368" y="150" fill="var(--c-server-ink)">/stripe/webhook</text>
<text class="sub" x="368" y="168" fill="var(--c-server-ink)">/entitlements/course</text>
<text class="sub" x="368" y="186" fill="var(--c-server-ink)">/auth/* · /healthz · /docs</text>
<text class="sub" x="368" y="208" fill="var(--c-server-ink)">holds every secret</text>
<text class="grp" x="700" y="20">Outside</text>
<rect class="bx" x="700" y="30" width="184" height="42" fill="var(--c-out)" stroke="var(--c-out-line)"/>
<text class="lbl" x="714" y="56" fill="var(--c-out-ink)">Groq · Gemini</text>
<rect class="bx" x="700" y="82" width="184" height="42" fill="var(--c-out)" stroke="var(--c-out-line)"/>
<text class="lbl" x="714" y="108" fill="var(--c-out-ink)">Stripe</text>
<rect class="bx" x="700" y="134" width="184" height="42" fill="var(--c-out)" stroke="var(--c-out-line)"/>
<text class="lbl" x="714" y="160" fill="var(--c-out-ink)">Supabase · Postgres</text>
<rect class="bx" x="700" y="186" width="184" height="42" fill="var(--c-out)" stroke="var(--c-out-line)"/>
<text class="lbl" x="714" y="212" fill="var(--c-out-ink)">Google Identity</text>
<text class="grp" x="16" y="292">Shared · lib/</text>
<rect class="bx" x="16" y="302" width="868" height="62" fill="var(--c-shared)" stroke="var(--c-shared-line)"/>
<text class="sub" x="32" y="328" fill="var(--c-shared-ink)">api-spec (openapi.yaml) → api-zod · api-client-react | db (drizzle · course_purchases)</text>
<text class="sub" x="32" y="348" fill="var(--c-shared-ink)">one contract, generated — the contract suite asserts spec ↔ schema ↔ what the server returns</text>
<path class="edge" marker-end="url(#ar)" d="M192 59 H352"/>
<text class="edge-lbl" x="212" y="50">/api · same origin</text>
<text class="edge-lbl" x="212" y="131">decks and portfolio</text>
<text class="edge-lbl" x="212" y="146">reach nothing</text>
<path class="edge" marker-end="url(#ar)" d="M548 60 H700"/>
<text class="edge-lbl" x="566" y="50">server key only</text>
<path class="edge" marker-end="url(#ar)" d="M548 103 H700"/>
<path class="edge" marker-end="url(#ar)" d="M548 155 H700"/>
<path class="edge" marker-end="url(#ar)" d="M548 207 H700"/>
<text class="edge-lbl" x="566" y="198">verifies ID tokens</text>
<path class="edge" stroke-dasharray="4 4" d="M450 224 V302"/>
<path class="edge" stroke-dasharray="4 4" d="M104 254 V302"/>
<path class="edge" stroke-dasharray="5 4" marker-end="url(#ar)"
d="M104 88 C 104 262, 240 262, 620 262 C 672 262, 672 100, 700 62"/>
<text class="edge-lbl" x="250" y="278">bring-your-own-key — the browser calls the vendor itself, no server involved</text>
</svg>
<figcaption>
The shape to notice: thirteen client apps, one server, and every credential on
one side of that line. A deck is static and talks to nothing. The academy is
the only client with a server at all, and it works without one — the dashed
path is the bring-your-own-key fallback, which is what a GitHub Pages build
falls back to because Pages serves the static output and nothing else.
</figcaption>
</figure>
<p>
The build output is static files. At runtime the browser is the whole application:
it holds the key, it formats the request, it talks to the model vendor directly.
The optional <code>/api</code> hop exists only so first-time visitors get a working
demo without owning a key.
</p>
<div class="flow">
<div class="stage">
<span class="k">1 · Static host</span>
<p><code>dist/public</code> served under a base path. No server rendering, no session, no database.</p>
</div>
<div class="arrow">→</div>
<div class="stage">
<span class="k">2 · Browser SPA</span>
<p>React mounts, resolves language and theme from <code>localStorage</code>, renders one route.</p>
</div>
<div class="arrow">→</div>
<div class="stage">
<span class="k">3 · Provider layer</span>
<p><code>callAI()</code> picks a transport: the user key straight to the vendor, or the shared proxy.</p>
</div>
<div class="arrow">→</div>
<div class="stage">
<span class="k">4 · Model vendor</span>
<p>Gemini, Anthropic or OpenAI. The response is normalised to a plain string before it leaves the layer.</p>
</div>
</div>
<h3>The two transports</h3>
<div class="grid">
<div class="box">
<span class="tag">default path</span>
<b>Bring your own key</b>
<span>
The key stays in <code>localStorage</code> and is attached to a <code>fetch()</code> made
from the page itself. Anthropic requires
<code>anthropic-dangerous-direct-browser-access</code> for this to be allowed at all —
that header is what makes browser-origin calls legal, and it is set deliberately.
</span>
</div>
<div class="box">
<span class="tag">fallback path</span>
<b>Shared server proxy</b>
<span>
On mount the app probes <code>GET /api/ai/config</code>. If it reports Gemini as available
and the user has not opted into their own key, calls route through
<code>POST /api/ai/generate</code> instead, and no key ever reaches the client.
A failed or missing probe simply returns <code>{}</code> — the site then insists on a user key
rather than breaking.
</span>
</div>
</div>
<div class="callout">
<b>Why the dev proxy exists.</b> Vite's SPA fallback answers <code>/api/*</code> with
<code>index.html</code> at HTTP 200, so <code>res.ok</code> passes and <code>res.json()</code>
chokes on HTML — the site would silently conclude no server key exists and grey out the panel.
<code>vite.config.ts</code> therefore proxies <code>/api</code> to the sibling
<code>api-server</code> package (<code>API_PORT</code>, default <code>8787</code>) in development.
In production one origin serves both, so the relative path just works.
</div>
<!-- ───────────────────────── 3 ───────────────────────── -->
<h2 id="composition">3 · Composition</h2>
<p>
<code>App.tsx</code> is a provider stack and nothing else. Each layer is there for a
reason, and the order matters in exactly one place: <code>ProviderContextProvider</code>
reads translated error strings, so it has to sit inside <code>LocaleProvider</code>.
</p>
<div class="stack">
<div class="lvl depth1"><span class="nm">ErrorBoundary</span> — <span class="why">Outermost, because the failure it catches is a whole-page failure. Several things below throw on purpose — every <code>useX must be used within XProvider</code> guard, and the decks' manifest parser — and without something to land in, each of them took the document to blank white.</span></div>
<div class="lvl depth2"><span class="nm">TooltipProvider</span> — <span class="why">Radix tooltip root for the shadcn/ui primitives.</span></div>
<div class="lvl depth3"><span class="nm">LocaleProvider</span> — <span class="why">Resolves the language once, exposes <code>locale</code> / <code>S</code>, stamps <code>lang</code> and <code>dir</code> on <code><html></code>.</span></div>
<div class="lvl depth4"><span class="nm">AuthProvider</span> — <span class="why">Google sign-in. Inside <code>LocaleProvider</code> because Google renders the button's own label and has to be handed the current language.</span></div>
<div class="lvl depth5"><span class="nm">ProgressProvider</span> — <span class="why">Per-tool progress, so the launcher can offer to continue where the reader stopped.</span></div>
<div class="lvl depth6"><span class="nm">ProviderContextProvider</span> — <span class="why">Owns provider/model/key state and the <code>callClaude</code> / <code>callGrounded</code> functions every AI feature uses.</span></div>
<div class="lvl depth7"><span class="nm">WouterRouter</span> — <span class="why">Base path taken from <code>import.meta.env.BASE_URL</code>, so the app works under a sub-path deploy.</span></div>
<div class="lvl depth8"><span class="nm">Switch → HomePage | NotFound</span> — <span class="why">One real route. The router is here for the sub-path handling and the 404, not for navigation.</span></div>
</div>
<h3>Below the router</h3>
<p>
<code>HomePage</code> owns two pieces of page-level chrome — the theme and the nav drawer —
and then renders the sections. Everything else a section needs, it pulls
from context itself. That is the reason the page component stays under 100 lines while
the sections it renders are hundreds.
</p>
<pre><code>HomePage
├── skip link · ScrollProgress · NavToggle · NavScrim · BackToTop
├── Nav ← theme toggle, language toggle, scroll-spy, Google sign-in
├── Hero
└── main
├── ToolLauncher → the hub; unnumbered, not in the nav
├── 01 ResumeAgent → callClaude() + extractJSON()
├── 02 LectureSeries → lib/lectures.ts, no network
├── 03 InterviewAgent → callClaude() with a rolling message array
├── 04 QuestionBank → callGrounded() + extractJSON()
├── 05 CodingChallenges → locale content only, no network
│ └── details × 3, closed → ChallengeCard × 40 → useDisclosure(3)
└── 06 ConnectionSetup → useProviderContext()</code></pre>
<div class="callout">
<b>One list decides the order.</b> <code>lib/sections.ts</code> exports <code>SECTIONS</code>
— id and number, in reading order — and both <code>HomePage</code> and <code>Nav</code>
render from it. There used to be three answers to “what order are the sections in”: the
array in <code>locale.nav.links</code>, the JSX here, and the static prerender in
<code>index.html</code>. None of them agreed, so reading down the nav jumped the visitor
16237 → 1144 → 13693 → 2749 → 3648 → 4597 pixels, the scroll-spy highlight travelled
2 → 4 → 5 → 6 → 3 → 1 as you scrolled, and the numbered headings counted 01 → 03 → 02 with
three sections carrying no number at all. The numbers are stored rather than derived from
the array index, because they appear on screen: changing the order should be a visible
decision about renumbering, not a silent one.
</div>
<h3>Below the API</h3>
<p>
The server is composed the same way, and for the same reason. <code>app/main.py</code> once
held 870 lines — app setup, CORS, both middleware layers, auth, content, the AI proxy, Stripe
and entitlements — which meant every one of those changed in the same file. It is now a
composition root of ~80 lines that builds the app and mounts a list.
</p>
<pre><code>app/main.py create_app() — CORS, middleware, handlers, routers
├── routes/__init__.py ROUTERS — the mount list; create_app iterates it
│ ├── ops.py healthz · readyz · metrics · the Scalar page
│ ├── auth.py Google sign-in → signed HttpOnly session
│ ├── content.py three routes, one shared code path
│ ├── ai.py quota, metrics, and the proxy call
│ ├── commerce.py seed · checkout · prices · webhook
│ └── entitlements.py has this verified identity bought the course
├── dependencies.py every Depends provider + the four rate limiters
├── ai_gateway.py one strategy object per vendor, and the dispatch
├── content_store.py Supabase reads + the NestedCollection specs
├── commerce.py checkout, seeding, prices, webhook trust decision
├── catalog.py the one course this deployment may sell, or None
├── entitlements.py purchase lookup against that catalogue
├── origins.py which origins a redirect may point at
├── errors.py ServiceError, rendered by a single handler
└── schemas.py · settings.py request bodies · deployment-wide limits</code></pre>
<div class="callout good">
<b>The routes hold HTTP and nothing else.</b> A route reads its body, hands the work to a
service, and returns. Failures are raised as <code>ServiceError</code> and rendered once, so
the <code>{"error": …}</code> shape every client depends on is defined in one place rather
than in a <code>JSONResponse</code> literal per branch. The three content routes are the
clearest case: they were three near-identical 35-line handlers, and they are now one call
each against a <code>NestedCollection</code> that describes the tables as data.
</div>
<div class="callout good">
<b>Collaborators arrive through <code>Depends</code>, which is what made the services
testable.</b> Tests used to reach into <code>app.main</code> and reassign
<code>stripe_client</code> — a seam that exists only because there was nowhere else to put
one, and that breaks the moment a function moves. They now install a fake through
<code>app.dependency_overrides</code>, and most of the new coverage skips the request
entirely: a provider's model fallback and a webhook's trust decision are rules, and a test
that constructs the service names the rule instead of a route.
</div>
<div class="callout good">
<b>Scalar documents the server that is actually running.</b> FastAPI serves its runtime
OpenAPI document at <code>/api/openapi.json</code> and the interactive Scalar reference at
<code>/api/docs</code>. The Scalar agent, telemetry, proxy, remote fonts, and credential
persistence are disabled; its version-pinned browser code sends test requests directly to
the same API origin. Replit relays both paths to Fly without storing a backend secret.
</div>
<div class="callout good">
<b>The shape to notice.</b> No component imports a vendor SDK, a URL, or a key.
They import <code>useProviderContext()</code> and call a function. Swapping a vendor,
adding a proxy, or changing a header touches <code>lib/providers.ts</code> alone.
</div>
<!-- ───────────────────────── 4 ───────────────────────── -->
<h2 id="ai">4 · The AI layer</h2>
<p>
<code>lib/providers.ts</code> is the only file that knows any vendor exists.
<code>PROVIDERS</code> is a registry of strategy objects — one per vendor — behind a
uniform interface, so the call site never branches on which one is selected.
</p>
<pre><code>interface ProviderDef {
label: (S) => string; // translated field label
placeholder: string; // key format hint, e.g. 'sk-ant-...'
models: string[]; // first entry is the default
validateKey?: (key, S) => void; // fail fast on an obviously wrong key
build: (key, model, system, messages, maxTokens)
=> { url, headers, body }; // vendor request shape
parse: (d) => string; // vendor response → plain text
keyHint?: (key, status, S) => string; // extra help on a 4xx
}</code></pre>
<div class="scroll">
<table>
<thead>
<tr><th>Vendor</th><th>Endpoint</th><th>Auth</th><th>Models offered</th><th>Notes</th></tr>
</thead>
<tbody>
<tr>
<td><b>Gemini</b></td>
<td><code>generativelanguage.googleapis.com/v1beta</code></td>
<td><code>x-goog-api-key</code></td>
<td>2.5-flash · 2.5-flash-lite · 2.5-pro</td>
<td>Flash models get <code>thinkingBudget: 0</code>; the only vendor with a proxy path and the only one with web grounding.</td>
</tr>
<tr>
<td><b>Anthropic</b></td>
<td><code>api.anthropic.com/v1/messages</code></td>
<td><code>x-api-key</code></td>
<td>Claude Sonnet 5 · Claude Haiku 4.5</td>
<td>Needs <code>anthropic-version</code> plus the direct-browser-access opt-in header.</td>
</tr>
<tr>
<td><b>OpenAI</b></td>
<td><code>api.openai.com/v1/chat/completions</code></td>
<td><code>Authorization: Bearer</code></td>
<td>GPT-5-mini · 5.4-mini · 5.4</td>
<td>System prompt is prepended as a message rather than sent as its own field.</td>
</tr>
</tbody>
</table>
</div>
<h3>Three exported entry points</h3>
<dl class="kv">
<dt>callAI()</dt>
<dd>The general path. Chooses proxy vs direct, validates the key, builds, sends, and parses — returning a plain string.</dd>
<dt>callGeminiGrounded()</dt>
<dd>Adds the <code>google_search</code> tool so answers cite live sources. Used only by the question-bank enrichment.</dd>
<dt>extractJSON()</dt>
<dd>Pulls a JSON object <em>or</em> array out of a model reply — strips a <code>```json</code> fence, scans for the first balanced value (string-aware, so trailing citations don’t leak in), and repairs a near-miss reply with <code>jsonrepair</code> (missing or trailing commas, unescaped quotes, a truncated tail) before parsing. This is what makes “return only JSON” prompts survive a chatty, imperfect model.</dd>
</dl>
<h3>Failure handling is part of the contract</h3>
<ul class="notes">
<li>A <b>thrown <code>fetch</code></b> (CORS, offline, blocked host) is translated into an explanatory message naming the host that was refused — not a bare <code>TypeError</code>.</li>
<li>A <b>non-2xx</b> response carries the status plus the first 300 characters of the body, so the user sees the vendor's own complaint.</li>
<li>A <b>wrong-shaped key</b> is rejected before any request is made, via <code>validateKey</code> — pasting an <code>sk-ant-</code> key into the OpenAI slot fails locally.</li>
<li>Every error string comes from the active locale, so the failure path is translated as carefully as the happy path.</li>
</ul>
<!-- ───────────────────────── 5 ───────────────────────── -->
<h2 id="content">5 · Content & i18n</h2>
<p>
<code>en.ts</code> is the source of truth for structure; <code>he.ts</code> is declared as
<code>const he: Locale</code> where <code>Locale = typeof en</code>. That single annotation
means the compiler rejects a Hebrew file that drops a key, misspells one, or falls behind
a new section. Translation drift is a build error, not a runtime blank.
</p>
<div class="grid">
<div class="box">
<span class="tag">resolution order</span>
<b>Which language wins</b>
<span>
<code>?lang=</code> query param → <code>localStorage.ata_lang</code> →
<code>navigator.language</code> starting with <code>he</code> → <code>en</code>.
The result is written back to storage, so a link with <code>?lang=he</code> is sticky.
</span>
</div>
<div class="box">
<span class="tag">direction</span>
<b>RTL is data, not a branch</b>
<span>
Each locale carries its own <code>dir</code>. <code>applyHtmlAttrs()</code> stamps
<code>lang</code> and <code>dir</code> onto <code><html></code> and CSS logical
properties do the rest — no component checks the language.
</span>
</div>
<div class="box">
<span class="tag">switching</span>
<b>Full reload, on purpose</b>
<span>
<code>switchLang()</code> writes the choice and reloads with the new query param.
A reload guarantees direction, fonts and third-party widgets all re-lay out cleanly,
which a re-render does not.
</span>
</div>
<div class="box">
<span class="tag">prompts</span>
<b>Prompts live in the locale</b>
<span>
The resume, rewrite and interview system prompts sit under <code>locale.prompts</code>,
so tuning model behaviour is a content edit — and each language can steer the model
in its own words.
</span>
</div>
</div>
<div class="callout">
<b>Where a string lives depends on how much of it there is.</b> Short copy sits in
<code>lib/locales/{en,he}.ts</code>. Bodies of content large enough to drown a locale file
get their own module — <code>lib/questionBank.ts</code> and <code>lib/lectures.ts</code> —
exporting an <code>EN</code>/<code>HE</code> pair with the same compiler-checked shape.
The lecture catalogue lived inside <code>components/practice/LectureSeries.tsx</code> until it did
not: 336 of that file's 425 lines were the two data objects, which made editing a blurb an
edit to a component and left the catalogue with no importer other than the thing that
rendered it. Moving it out is also what lets the prerender generator read it, since a build
script cannot import from a component.
</div>
<p>
One shape in the nav is worth calling out: <code>locale.nav.labels</code> is keyed by section
id, not ordered. The order is <code>lib/sections.ts</code>; only the words are translated.
</p>
<h3>The challenge content model</h3>
<p>
<code>lib/challenges.ts</code> declares types only — no data. The UI depends on the shape,
and the shape is currently satisfied by the locale files; an API or CMS could satisfy it
later without touching a component.
</p>
<pre><code>Challenge { title, prompt, hint, code, complexity }
ChallengeLevel { label, blurb, items: readonly Challenge[] }
ChallengeLabels{ hint, complexity, showHint, showSolution, hide }</code></pre>
<div class="scroll">
<table>
<thead><tr><th>Level</th><th>Focus</th><th>Count</th></tr></thead>
<tbody>
<tr><td><b>1 — Fundamentals</b></td><td>Lists, dicts, strings, a little recursion. Single clean passes, no tricks.</td><td>20</td></tr>
<tr><td><b>2 — Interview standard</b></td><td>Decorators, polling, schema validation, log diffing, generators.</td><td>10</td></tr>
<tr><td><b>3 — Advanced</b></td><td>Intervals, caching, graphs, concurrency, streaming and bisection.</td><td>10</td></tr>
</tbody>
</table>
</div>
<!-- ───────────────────────── 6 ───────────────────────── -->
<h2 id="features">6 · Feature modules</h2>
<h3>📄 Resume Agent — a pipeline, not a component</h3>
<p>
It is really a small document pipeline — read a file, extract text, score it, rewrite it,
export it, all client-side — and for a long time it was also one 695-line function holding
every stage of that. Each stage now lives in the module that owns it, and the component is
~220 lines of wiring and form: <code>lib/documentText.ts</code> reads,
<code>lib/resumeExport.ts</code> writes, and <code>useResumeDrafts</code>,
<code>useResumeUpload</code> and <code>useResumeEvaluation</code> hold the state between.
A change to how a DOCX is parsed no longer touches the JSX.
</p>
<div class="flow">
<div class="stage"><span class="k">extract</span><p><b>pdf.js</b> or <b>mammoth</b> turns an uploaded PDF/DOCX into plain text in the browser.</p></div>
<div class="arrow">→</div>
<div class="stage"><span class="k">score</span><p><code>callClaude()</code> with the resume prompt, then <code>extractJSON()</code> into scores, strengths, gaps.</p></div>
<div class="arrow">→</div>
<div class="stage"><span class="k">rewrite</span><p>A second call with the improve prompt returns ATS-friendly prose, not JSON.</p></div>
<div class="arrow">→</div>
<div class="stage"><span class="k">export</span><p><code>lib/resumeExport.ts</code> picks a builder; <code>lib/resumePdf.ts</code> builds it with <b>jsPDF</b>, embedding a Hebrew face for RTL.</p></div>
</div>
<p>
All four libraries are <b>bundled dependencies</b>, reached through dynamic
<code>import()</code> so they are fetched on first use rather than on page load. Nothing is
pulled from a public CDN: the runtime-injection loader that used to walk
cdnjs → jsDelivr → unpkg is gone, along with the exposure it carried. The cost has moved
from CDN reachability to size — pdf.js and its worker are about 1.6 MB, and fetching them
is the slowest part of the first upload by a wide margin.
</p>
<div class="callout">
<b>The export must be text, not a picture.</b> An applicant tracking system reads the text
layer, so a résumé rendered as an image fails the first automated sift while looking perfect
to the person holding it. That is why the Hebrew path embeds a font and reorders each line
into visual order rather than rasterising, and why <code>pdfFromCanvas()</code> survives only
as the fallback for when the font itself cannot be fetched.
</div>
<p>
Formats are a registry rather than a chain of <code>if</code>s. <code>EXTRACTORS</code> maps an
extension to one object with an <code>extract()</code> and a <code>slowToLoad</code> flag, so
adding a format is an entry in that map — the size limit, the length bounds and the label
narration around it are written once and do not know how many formats exist. The flag is what
decides whether the zone says “getting ready” before it can count anything: pdf.js and its
worker are the wait, and mammoth is not.
</p>
<p>
The same principle runs in reverse on the way in. The PDF extractor counts the text
runs pdf.js finds, and a file with none is reported as what it is — a scan, or a résumé
exported as an image — rather than as a generic extraction failure, because the reader whose
only copy is a picture has a problem well beyond this upload. Reading also reports progress:
the zone names the wait for the parser itself, then counts pages as it goes, so a large file
never leaves one unchanging line on screen. A <code>.txt</code> reports no run count at all
rather than zero, so an empty one is called a failed extraction and not a scan — the two need
different things said to whoever uploaded them. The file input is cleared after every pick, since
a picker holding the same file fires no <code>change</code> event and the obvious retry would
otherwise do nothing at all.
</p>
<h3>🔐 Sign in with Google</h3>
<p>
<code>AuthProvider</code> plus a <code>GoogleSignIn</code> control in the nav drawer. Google's
hosted client is loaded on demand, draws its own button, and hands back an ID token. The
browser immediately posts that credential to <code>POST /api/auth/google</code>; it never
decodes or persists it. Google renders the button's label itself, so the current language is
passed to <code>renderButton</code> — that half of the translation cannot come from a strings
file.
</p>
<p>
<b>The browser trusts only the API response.</b> While verification is in flight the nav shows
a localized progress state; a refusal or network failure leaves the visitor signed out and
shows a localized error. On success the response contains only the public profile fields the
header needs. No client-side claim grants access to anything.
</p>
<p>
<b>The Python server verifies before it creates a session.</b>
<code>app/google_auth.py</code> pins RS256, selects a key from Google's cached JWKS, verifies
the signature, issuer, audience, expiry, issue/not-before times and
<code>email_verified</code>, then exchanges the result for an HMAC-signed cookie. The cookie is
<code>HttpOnly</code>, <code>SameSite=Lax</code>, secure in production, scoped to
<code>/api</code>, and cannot outlive the Google token. Login is rate-limited to ten attempts
per IP per five minutes.
</p>
<p>
Reloads restore the public profile through <code>GET /api/auth/session</code>; logout deletes
the cookie through <code>POST /api/auth/logout</code>. Entitlements and checkout accept that
verified session. Bearer ID tokens remain accepted on those server endpoints for API-client
compatibility, but the academy UI uses the cookie path.
</p>
<p>
A build with no <code>VITE_GOOGLE_CLIENT_ID</code> renders no sign-in at all and never
contacts Google, so a deployment that has not been given an OAuth client stays whole rather
than showing a button that cannot work.
</p>
<p>
Sign-in requires the static app and API to share an origin (or an equivalent reverse proxy
for <code>/api</code>). GitHub Pages is static-only: leave its repository variable unset unless
such a proxy exists, otherwise verification correctly ends in the visible signed-out error.
</p>
<h3>🎙️ Interview Agent</h3>
<p>
A rolling <code>Message[]</code> is kept in a ref and resent whole on every turn — the model
holds the conversation, the client holds no session. The five-stage structure lives entirely
in <code>locale.prompts.interview</code>. Ending the interview sends the sentinel
<code>___VERDICT___</code>, which the prompt binds to “stop and produce a scored verdict”.
</p>
<h3>❓ Question Bank</h3>
<p>
Five collapsed stages, each holding questions that are themselves the control: one click
reveals a hint, a second reveals the full answer, a third collapses. That is
<code>QuestionCard</code> over the same <code>useDisclosure(3)</code> the coding challenges
use, so both sections of the site behave identically and neither owns stage logic.
</p>
<p>
Content — 75 questions per language (fifteen per stage, 150 in all), each with a hint and a
multi-paragraph answer — lives in <code>lib/questionBank.ts</code> rather than in the
component, which would otherwise be mostly prose. The section renders whatever the bank
contains and never counts stages or items.
</p>
<p>
The enrich action feeds the user's role and a fixed keyword seed into
<code>callGrounded()</code>, then <code>extractJSON()</code> lifts a <code>questions</code>
array out of the reply. Grounding is why this one path is Gemini-only.
</p>
<p>
The API counterpart is <code>GET /api/content/question-bank?lang=en|he</code>. It reads
ordered stages and items from Supabase and returns the same <code>QuestionBank</code> shape;
an unavailable content store produces a deliberate <code>503</code>. The browser currently
uses the bundled module, so this endpoint is the migration boundary rather than a dependency
of the static build.
</p>
<h3>🐍 Coding Challenges</h3>
<p>
The section maps levels to <code>ChallengeCard</code>s and knows nothing else. Each card owns
its own <code>useDisclosure(3)</code> — prompt → hint → solution → collapsed — so the list
never tracks a stage per index and adding challenges cannot regress the reveal logic.
</p>
<p>
<code>GET /api/content/coding-challenges?lang=en|he</code> exposes the ordered level and
challenge model from Supabase. Lecture tracks use the corresponding
<code>GET /api/content/lecture-series?lang=en|he</code> endpoint. Both default invalid or
missing language values to English and return the same controlled <code>503</code> when the
content store cannot be reached.
</p>
<!-- ───────────────────────── 7 ───────────────────────── -->
<h2 id="state">7 · State & persistence</h2>
<p>
There is no store. State is React state in two contexts plus local component state, and the
only thing that survives a reload is what is deliberately written under the <code>ata_</code>
prefix — to <code>localStorage</code> when it should outlive the visit, to
<code>sessionStorage</code> when it should not.
</p>
<div class="callout">
<b>Storage is input, not state.</b> Every read goes through <code>lib/storage.ts</code>, for
two reasons that apply to all eleven keys. Storage can be unavailable — private browsing,
blocked cookies, a full quota — and <code>getItem</code> <em>throws</em> rather than returning
null when it is, which in a module-scope initialiser takes the page down before it renders.
And the contents are editable from any devtools console, so a value read back has to be
validated like anything else that arrives from outside. Every failure is the same answer: the
value is absent, and the caller's default applies.
</div>
<div class="scroll">
<table>
<thead><tr><th>Key</th><th>Written by</th><th>Where</th><th>Holds</th></tr></thead>
<tbody>
<tr><td><code>ata_theme</code></td><td>HomePage · read by the head script</td><td>local</td><td>Exactly <code>light</code> or <code>dark</code> — checked, because this value lands on <code>data-theme</code> and the toggle only ever flips between those two. First visit falls back to <code>prefers-color-scheme</code>. The inline script in <code>index.html</code> resolves it and stamps the attribute before first paint; <code>HomePage</code> reads the stamped value back rather than re-deriving it, so there is one copy of the priority rules rather than two free to disagree.</td></tr>
<tr><td><code>ata_lang</code></td><td>i18n</td><td>local</td><td>Resolved language, checked against the catalogue. Re-written on every resolve so it stays sticky.</td></tr>
<tr><td><code>ata_progress_v1</code></td><td>ProgressContext</td><td>local</td><td>Which tools were started and finished. Field-by-field coercion with a bounded array — the pattern the rest of the keys were brought up to.</td></tr>
<tr><td><code>ata_provider</code></td><td>ProviderContext</td><td>local</td><td>Selected vendor, checked against the providers that exist; defaults to <code>groq</code>. Checking it against the current list is also what retires a stale preference — Gemini is search-only now and not a selectable chat provider, so a visitor holding one falls through to the default.</td></tr>
<tr><td><code>ata_key_<provider></code></td><td>ProviderContext</td><td>local</td><td>The user's own API key, one slot per vendor, saved only while “remember” is on.</td></tr>
<tr><td><code>ata_session_key_<provider></code></td><td>ProviderContext</td><td>session</td><td>The same key when “remember” is off — the default. Keys written by older versions are migrated here on first read.</td></tr>
<tr><td><code>ata_remember_key_<provider></code></td><td>ProviderContext</td><td>local</td><td>Which of the two slots above is in use.</td></tr>
<tr><td><code>ata_google_credential</code></td><td>AuthContext migration cleanup</td><td>local</td><td>Legacy only. Current code deletes this raw-token key on mount and never writes it; authentication persists solely in the server-issued HttpOnly cookie.</td></tr>
<tr><td><code>ata_interview_session_v1</code></td><td>InterviewAgent</td><td>session</td><td>A partly-finished interview, so a reload does not lose it. Every element validated: <code>text</code> is rendered as a child and <code>cls</code> as a class name.</td></tr>
<tr><td><code>ata_resume_draft_<lang>_*</code></td><td>ResumeAgent</td><td>session</td><td>The résumé, target role and job description in progress. Three keys per language.</td></tr>
</tbody>
</table>
</div>
<p>
<code>resetSettings()</code> deliberately does <b>not</b> sweep the whole prefix. It removes
the provider selection, the per-vendor keys and their remember flags, and nothing else:
resetting which model you talk to should not also erase your progress, your interview
history or your résumé drafts. Authentication is a server cookie and is not part of settings.
</p>
<div class="callout good">
<b>Why the transcript and the drafts are session-scoped.</b> Both hold what someone wrote
about their own career, on a machine that may not be theirs. The résumé drafts were always
<code>sessionStorage</code>; the interview transcript was <code>localStorage</code> and
permanent, which was the same class of content on the opposite lifetime for no stated reason.
It is a session now, which is exactly as long as “resume after a reload” needs. Transcripts
left behind by the old version are deleted rather than migrated — carrying one into the
session would carry the problem forward a visit.
</div>
<h3>The key-mode decision</h3>
<p>
One subtlety worth naming: <code>ownKeyTouched</code>. Until the user explicitly toggles the
switch, the app re-derives the mode whenever the provider changes or the server config
arrives — a stored key or the absence of a server default turns own-key mode on.
Once the user touches it, their choice is respected and never recomputed.
</p>
<p>
The flag is held in a ref as well as in state, because the effect has to <em>read</em> it
without <em>re-running</em> on it: the effect exists to re-evaluate the default when the
provider changes or the config lands, and firing it because someone just flipped the toggle
would undo the flip. That is a dependency the effect genuinely must not have, which is worth
saying in a ref rather than in a suppression comment.
</p>
<!-- ───────────────────────── 8 ───────────────────────── -->
<h2 id="ui">8 · UI mechanics</h2>
<div class="grid">
<div class="box">
<span class="tag">hooks/useDisclosure</span>
<b>Stepped reveal</b>
<span>A modulo counter over N stages. It knows nothing about hints or solutions, which is why the same hook drives any progressive-reveal card.</span>
</div>
<div class="box">
<span class="tag">hooks/useReveal</span>
<b>Scroll-in animation</b>
<span>One <code>IntersectionObserver</code> per section adds <code>.in</code> and then unobserves, so each element animates exactly once and the observer disconnects on unmount.</span>
</div>
<div class="box">
<span class="tag">Nav</span>
<b>Scroll-spy</b>
<span>A second observer with a <code>-25% / -65%</code> root margin highlights whichever section is in the reading band, not merely on screen.</span>
</div>
<div class="box">
<span class="tag">HomePage</span>
<b>Drawer without a flash</b>
<span>The <code>nav-ready</code> class lands after a double <code>requestAnimationFrame</code>, so CSS transitions are only enabled after the first paint and the drawer cannot animate on load.</span>
</div>
<div class="box">
<span class="tag">Nav</span>
<b>Drawer that is really closed</b>
<span>Below 900px the sidebar is moved off-screen by a transform, which hides it from eyes and from nobody else: it kept <code>visibility: visible</code> and all twelve of its controls stayed in the tab order, so keyboard focus vanished for twelve stops before reaching the page. <code>inert</code> takes the subtree out of focus and out of the accessibility tree in one inherited attribute.</span>
</div>
<div class="box">
<span class="tag">ScrollProgress</span>
<b>One write per frame</b>
<span>The bar's width is written straight to the element inside a <code>requestAnimationFrame</code> rather than held in React state — a scroll handler that calls <code>setState</code> re-renders on every event of a long page to move one CSS property.</span>
</div>
<div class="box">
<span class="tag">lib/domUtils</span>
<b>Safe rendering of model text</b>
<span><code>esc()</code>, <code>linkifyHtml()</code> and <code>findLinks()</code> escape first and only then insert <code><strong></code>/<code><a></code> — markdown from a model is rendered without handing it an HTML injection.</span>
</div>
<div class="box">
<span class="tag">lib/domUtils</span>
<b>Mixed-direction output</b>
<span><code>isRtlText()</code> detects Hebrew per block, so a model reply can be aligned correctly even when it does not match the page language.</span>
</div>
</div>
<h3>Accessibility affordances already in place</h3>
<ul class="notes">
<li>A skip link to <code>#main-content</code>, translated like everything else.</li>
<li><code>aria-expanded</code> on every disclosure button, and <code>aria-label</code> on the icon-only nav and language toggles.</li>
<li>Theme is an attribute on <code><html></code> (<code>data-theme</code>), so CSS — not JS — resolves colour, and the first paint is already correct. That last clause is newly true: the attribute used to be written in an effect after React mounted, and because this page's own app ships a static prerender, first contentful paint put finished content on screen in the light palette and the whole page then flipped.</li>
<li>Both <code>lang</code> and <code>dir</code> are set on the document element, which is what screen readers and the bidi algorithm actually read.</li>
<li>The mobile drawer behaves as the modal it looks like: <code>inert</code> while closed, Escape to dismiss, focus moved to the first link on open and returned to the toggle on close, and an <code>aria-label</code> that follows <code>aria-expanded</code> instead of announcing “Open navigation menu, expanded”.</li>
</ul>
<div class="callout">
<b>Focus return is keyed off the transition, not off where focus is.</b> The obvious
implementation asks whether <code>document.activeElement</code> is <code><body></code>
and puts focus back if so. It silently never fires: by the time the effect runs React has
already applied <code>inert</code>, and the browser blurs whatever was inside the subtree
when it does, so there is nothing left to recognise. Tracking the open → closed edge is what
actually observes the event, and it also stops a drawer that was never opened from claiming
focus on mount.
</div>
<!-- ───────────────────────── 9 ───────────────────────── -->
<h2 id="security">9 · Trust boundaries</h2>
<div class="callout">
<b>Be explicit about the key.</b> In own-key mode the API key lives in
<code>sessionStorage</code> by default and is sent from the page to the vendor. A clearly
labelled “remember” choice moves it to <code>localStorage</code>. Either way any script on this
origin and the browser's network log can reach it; the proxy path exists precisely so a casual
visitor never has to make that trade.
</div>
<ul class="notes">
<li><b>Untrusted input is model output.</b> Everything a model returns is escaped before it reaches the DOM; the markdown renderer builds its tags from parsed groups, never by interpolating raw text.</li>
<li><b>No secret is bundled.</b> Server-side keys stay in <code>api-server</code>; the client only ever learns the boolean <code>available</code> and a default model name.</li>
<li><b>Error bodies are truncated</b> to 300 characters before display, which keeps a verbose upstream error from dumping a wall of text into the UI.</li>
<li><b>One third-party script, by necessity.</b> Google's sign-in client is loaded from <code>accounts.google.com</code> and runs on this origin — there is no packaged equivalent, the credential flow only exists inside the hosted client. It is the only runtime code on the site that does not ship with it, it is requested only when an OAuth client is configured, and the résumé libraries that used to be fetched from public CDNs are now bundled dependencies.</li>
<li><b>The raw Google token never becomes browser state.</b> It is posted once to the Python API and discarded. The UI renders only the verified public user returned by the server.</li>
<li><b>The session is server-authenticated and script-inaccessible.</b> The API checks Google's signature and claims before issuing its own short-lived, HMAC-signed HttpOnly cookie. Entitlement decisions are made from that verified identity, never from a browser-decoded claim.</li>
<li><b>A purchase record is never written by a browser.</b> Rows in <code>course_purchases</code> come from a signature-verified Stripe webhook and from nowhere else. The <code>googleSubject</code> a row carries is stamped into the checkout session from a token the server verified, so it is not a field any client can set — the checkout schema is strict and rejects the attempt.</li>
<li><b>The seed route is an admin route.</b> <code>POST /api/stripe/seed</code> writes a product and a price to a live Stripe account. It requires <code>ADMIN_API_TOKEN</code> as a bearer token, compared in constant time over hashes so a length mismatch cannot leak through an exception, and answers <code>404</code> rather than <code>401</code> when no token is configured — an unconfigured deployment should not advertise that the surface exists, and forgetting the variable should fail closed.</li>
</ul>
<div class="callout">
<b>The client ID is not a secret.</b> <code>VITE_GOOGLE_CLIENT_ID</code> is inlined into the
bundle by design and is readable by anyone who opens it; committing it changes nothing about
who can see it, and Google enforces which origins may use it. A client <i>secret</i> is a