Reviewed: 2026-09-27 · Bead: bd-ak1go · Build: main-bot staging @ 554c6a8d (the snapshot handler in ../src/ is byte-identical to staging head)
Scope: everything a coach or child sees: the 7 stimulus images, the Listening Comprehension WhatsApp Flow, and every coach-facing string in src/shared/handlers/egra-egma-ict-test.handler.js.
Method: downloaded all 7 real images, looked at each one full-size, and measured glyph heights in pixels, converted to millimetres on a 6.1" phone (64.9 × 140.7 mm screen). I rendered the Flow and the coach's chat as phone mockups using the real strings, and checked staging logs (Axiom) for what actually happened when testers ran it. Nothing in git, R2, Meta or Railway was changed. R2 access was GET-only.
ux-strings catalog. ICT coaches are Urdu-first.correct_answer_note exists for every question but no code shows it. Three EGMA items ask "How many bananas are there?" about pictures that were never made, and the coach is not told this.${data…} binding, which Meta's spec says needs backticks.What happens. sendGridSubtestIntro (handler.js:151-163) and sendVoiceNoteRequest (handler.js:274-276) call WhatsAppService.sendImageFromUrl(url, …). On staging, that function does a plain axios.get(imageUrl) with no credentials, then uploads the bytes to Meta (shared/services/whatsapp.service.js on origin/staging, sendImageFromUrl at ~line 1117). The URLs in src/shared/config/egra-egma-grid-images.json:3-7 and egra-egma-passage-images.json:3-4 are all https://58a6fb2a…r2.cloudflarestorage.com/digital-coach-audio/…. That is R2's private S3 API address, not a public one.
Evidence.
- curl of all 7 URLs returned 400 <Code>InvalidArgument</Code><Message>Authorization</Message>.
- Staging Axiom, 15–27 Sep: the only 3 EGRA image sends (19, 21 and 24 Sep, all listening_comprehension_urd.png) each log Fetching image from URL for WhatsApp upload → ❌ Error in sendImageFromUrl → whatsapp.message.failed "Request failed with status code 400". They then log egra_egma_ict_test.listening_flow_completed and send the voice-note request. The traces are corr-1789793344332-r6dner1ty, corr-1790017018281-ap2iptcms and corr-1790233612977-s9fje057m.
- No grid-image send appears in staging logs at all in that window, so no tester has yet seen a letter/word/ORF card on a phone.
Why tests are green. Both handler test files replace the send with an always-successful fake: shared/handlers/__tests__/egra-egma-ict-test.handler.test.js:15 (sendImageFromUrl: jest.fn().mockResolvedValue(true)) and tests/egra-egma-listening-flow-completion-routing.test.js:33. The URL test (grid-image-urls.test.js:13-15) only checks that each URL is a string starting with https://.
What the coach sees. img/chat_3_grid_as_delivered.png compared with img/chat_3_grid_as_intended.png, and img/chat_2_after_flow.png (the passage never arrives).
Fix direction. The code already has the right tools: getPresignedUrl() (shared/storage/r2.js:545) or downloadFromR2() (r2.js:366). Separately, check the true/false that sendImageFromUrl returns (handler.js:163, :276) and send the text fallback when it is false.
I looked at all 7 at full resolution (copies in img/, ≤1600 px viewing copies in img/view/).
src/shared/data/egra-egma-real-items.json:Every cell has a grey "N." label (grid-image.service.js:67, .num style :115-126). In a real EGRA the child's card is plain letters or words, and only the assessor's sheet is numbered. Numbers on the child's card distract a beginning reader, invite the child to read the numbers aloud, and make the page look like a test. They are also the reason the cells are tall and narrow (see 2d). The bidi fix that keeps "1." from flipping to ".1" (grid-image.service.js:120-125) is careful work, but the labels should not be on this card at all.
orf_eng.png and orf_urd.png put each word in its own numbered box, 10 per row (img/orf_eng.png, img/orf_urd.png). ORF measures reading a connected story. A box-per-word grid turns it into a word-list task: the child loses the sentence flow, and the results cannot be compared with ORF norms or with the paper instrument.
In English, punctuation is tokenised separately: boxes 7, 14, 23, 36, 41 (","), 54, 62 and 68 are just "." or ",". The item bank has token_count: 68 against word_count_excl_punctuation: 60. The handler uses 68 as the item count (item-bank.service.js:50), and the scorer counts every attempted box that is not marked wrong as a correct item (rate-scorer.js:54: correct = attempted - incorrect). So a child who reads all 60 English words correctly scores 68 per minute, not 60. Urdu keeps "۔" attached to the word (60 tokens = 60 words), so the two languages are not scored the same way.
Recommendation: render ORF as a normal paragraph, left-aligned for English and right-aligned for Urdu, in large type with no numbers. Put the numbered word list only on the coach's side. Score against the 60 words.
Measured from the real PNGs on a 6.1" phone, using WhatsApp's full-screen viewer (the image is fitted to the screen). The in-chat preview is smaller still.
| Card | Image shape | Best way to hold the phone | Letter / word height on screen | Roughly like printed text of |
|---|---|---|---|---|
| Letters, English | 920×1652, tall | upright (sideways makes it smaller) | capitals 3.5 mm, lower-case x-height 2.6 mm | ~14 pt |
| Letters, Urdu | 1368×2672, tall | upright | median 3.3 mm (1.4–5.0) | — |
| Words, Urdu | 1880×1222, wide | sideways | median 4.3 mm (2.8 mm upright) | — |
| ORF, English | 1754×954, wide | sideways | capitals 2.0 mm (1.1 mm upright) | ~8 pt (~4 pt upright) |
| ORF, Urdu | 2254×1332, wide | sideways | median 3.6 mm (2.1 mm upright) | — |
| Item-number labels | — | — | 1.3 mm (letters), 0.7 mm (ORF) | — |
See img/viewer_letter_id_urd.png, img/viewer_orf_eng_portrait.png and img/viewer_orf_eng_landscape.png. These PNGs are drawn at 2× so they look bigger on a monitor than on a real phone; the millimetres above are the true sizes.
The letter grids are tall because each cell stacks a number above the letter, and Nastaliq's tall line height adds more. A 10×10 card with no numbers, laid out wide, would roughly double the letter size on the same screen. The English ORF card is the worst: an 8-year-old reading 2 mm text against a clock is measuring eyesight and phone size as much as reading.
img/crops/letter_eng_row9.png). A child who says "l" is reading what they see. Use a school-style font where I and l differ (for example Andika, which is built for early literacy), or confirm the paper card's font. The two-storey "a" in Arial is also not the "a" most Grade 1 books teach.img/crops/letter_urd_row10.png shows item 100). It is legible, but it looks cramped. More cell padding would fix it.img/view/listening_comprehension_urd.png). It is coach-facing and readable, but it looks unfinished; drop the line or render it in Urdu.The grid caption tells the coach to "Show this to the child … then tap ▶️ Start" (handler.js:162). With one phone, the coach has to: 1. open the image full-screen and turn it to the child, 2. come back to the chat to tap Start (the child now sees the chat, not the card), 3. watch the clock, because nothing stops at 60 s, 4. remember the numbers of every wrong item while not looking at the screen, and 5. type them in afterwards ("Which item numbers did the child get WRONG? … e.g. 3,7,15", handler.js:197).
Paper EGRA and Tangerine both split this: the child reads from a printed card with no numbers, and the assessor marks a numbered copy. Recommendation: a printed, laminated child card set (letters, words, ORF story, EGMA numbers), with the phone as the coach's numbered marking sheet only. If the phone must be the child's card, the images need to lose the numbers and be laid out for the phone turned sideways, and the coach needs a separate way to record errors.
Renders (Meta Flow chrome, real text from docs/flows/egra-egma-listening-comprehension-flow.json and the item bank, filled exactly as sendListeningComprehensionFlow does at handler.js:238-256):
img/flow_read_passage_urd.png, img/flow_ask_questions_urd.png, img/flow_read_passage_eng.png, img/flow_ask_questions_eng.png. The chat message that opens it is at the bottom of img/chat_1_start.png. Every screen fits without scrolling.
Live status. Staging has EGRA_EGMA_LISTENING_FLOW_ID set (181656…), and the logs show 3 real submissions, so this Flow is published and in use. That goes beyond TESTING.md:153, which says it was never registered. I could not pull the published Flow JSON from Meta to diff it against the repo file, because that needs the staging WhatsApp token and was blocked.
| Check | Result |
|---|---|
| Component types for version 6.3 (TextHeading, TextSubheading, TextBody, TextCaption, Footer) | valid |
| One Footer per screen | yes (flow.json:62, :127) |
| Footer labels ≤35 | "Next — go to questions" 22, "Ready to record" 15 |
| TextHeading/Subheading ≤80 | 33 / 19 / 7 / 9 |
| TextCaption ≤409 | 243 |
| Whole passage in one TextBody ≤4096 | Urdu 307, English 360 characters; questions 205 / 223 |
| Audio assumed inside the Flow | no. The Flow only confirms, and the voice note happens in chat (flow.json:8 states this correctly) |
| Mixed literal text + binding | flow.json:47 "Listening Comprehension — ${data.language_label}". Meta's Flow JSON rule (as documented, e.g. Kapso's Flow JSON spec summary) is that "Hello ${data.name}" is invalid unless written as a backtick nested expression (v6.0+): "`'Listening Comprehension — ' ${data.language_label}`". No other Flow in docs/flows/ mixes the two. Unverified on a phone: check whether the heading shows "— Urdu" or the literal ${data.language_label}. |
sendVoiceNoteRequest re-sends only the passage (which currently fails, section 1), not the questions. Fix: ask the coach to start the voice note first ("press and slide up to lock"), then read the passage twice and ask the questions from a list that stays visible in chat.correct_answer_note (for example "Correct answer: Sunday") is never displayed anywhere in production code. The screen already says "Do not show this list to the child", so it is the natural place to show accepted answers.img/flow_ask_questions_eng.png). The Flow's __example__ (flow.json:27, :89) has the corrected wording, so anyone previewing in Flow Builder will not see the typo.img/flow_read_passage_*.png).${data.*} values so the Urdu run gets Urdu instructions.Chat mockups with the handler's exact strings (placeholder teacher and student names): img/chat_1_start.png (/egra → intro → class list → pick → Flow message) and img/chat_2_after_flow.png (Flow sent → voice-note ask → voice note → "Item 1/6" marking).
shared/config/ux-strings.js, which is where other features now keep their en/ur strings. The questions are Urdu for the Urdu subtests, so a coach sees "Item 1/6:" in English above an Urdu question. Rule 20 (language protocol) applies before any translation work.represent items ("How many bananas are there?", "What box has more apples?", "What number does the picture below show?") are flagged needs_real_image with a note that "the coach is shown the item's text description". But sendCurrentQaItem sends only item.prompt (handler.js:223-230), and no production code reads correct_answer_note, placeholder_note or needs_real_image. The coach gets a question about a picture that does not exist.truncateForWhatsAppRow (handler.js:95-98). That counts UTF-16 units, which is safe for Urdu names and only over-trims emoji.extractItemsFromHtml diffs the rendered HTML against the item bank before upload (grid-image.service.js:144-166).r2.js:545 / :366), check the return value, and fall back to text on failure. Add a test that calls the real download step against a mocked HTTP 400, not a mocked sendImageFromUrl → true.Images reviewed (downloaded from R2, read-only): img/letter_id_eng.png, img/letter_id_urd.png, img/familiar_word_urd.png, img/orf_eng.png, img/orf_urd.png, img/listening_comprehension_eng.png, img/listening_comprehension_urd.png (viewing copies in img/view/, zoomed crops in img/crops/).
Renders produced (all looked at): img/flow_read_passage_urd.png, img/flow_ask_questions_urd.png, img/flow_read_passage_eng.png, img/flow_ask_questions_eng.png, img/chat_1_start.png, img/chat_2_after_flow.png, img/chat_3_grid_as_delivered.png, img/chat_3_grid_as_intended.png, img/viewer_letter_id_urd.png, img/viewer_orf_eng_portrait.png, img/viewer_orf_eng_landscape.png.
Generator: render_design_review.py (re-run: python3 render_design_review.py; it uses the html-mockups chat rig; HTML intermediates are in html/).
Not verified:
- The published Flow JSON on Meta (see 3a), so the heading binding is unconfirmed.
- The in-chat image preview crop in chat_3_grid_as_intended.png, which is an approximation. The full-screen viewer renders are exact fits.