← EGRA Test Build Review · Design

EGRA/EGMA ICT test build: design review (child cards, Listening Flow, coach messages)

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.


The short version

  1. Blocker: no stimulus image has ever reached a coach. All 7 image URLs point at R2's private storage address, which refuses anonymous downloads (HTTP 400). The bot downloads by URL without credentials, so every send fails. Staging logs show 3 of 3 attempts failing, and the handler then carries on as if the send had worked.
  2. The failure is silent and takes the instructions with it. The fallback to plain text only runs when no URL exists, not when the send fails. The coach gets "Ready? ▶️ Start" with no grid, no 60-second rule and no discontinue rule.
  3. The images themselves are well made (clean, correct Unicode, correct Nastaliq, correct right-to-left order) but they are designed as a score sheet, not a child's card. Every item carries a printed number (1., 2., … 100.). A real EGRA child card has no numbers; numbers belong only on the assessor's sheet.
  4. Oral Reading Fluency is shown as 68 numbered word boxes, not as a story. The child is no longer reading connected text, so this is not an ORF measure. In English, full stops and commas get their own numbered boxes, and the scorer counts each one as a correctly read word. That adds up to 8 words to every English ORF score (+13% for a child who finishes).
  5. At phone size the print is small. English ORF words are about 2 mm tall even with the phone turned sideways (about 8-point print) and 1.1 mm upright. English letter-grid capitals are 3.5 mm. On a printed A4 card, each of the 10 columns gets about 27 mm; on the phone it gets about 6 mm.
  6. One phone cannot be both the child's card and the coach's score sheet. The coach has to hold the phone up to the child, keep track of which item numbers were wrong (up to 100), and tap Start and Stop on the same screen.
  7. Everything the coach reads is English-only, hardcoded in the handler rather than routed through the ux-strings catalog. ICT coaches are Urdu-first.
  8. Two instructions are wrong for the reading subtests. "You will read out each item to the child" and "(or read it out per the item type)" would make a coach read the letters and words aloud for the child, which invalidates those subtests.
  9. The coach never sees an answer key. 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.
  10. The Listening Flow is within Meta's limits but has problems. The steps are in the wrong order: the child answers before the recording starts. Item-bank typos ("On what did did Sara…", "WHo walked…") appear verbatim. The heading mixes literal text with a ${data…} binding, which Meta's spec says needs backticks.

1. Blocker: the images never arrive

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.


2. The child's cards (stimulus images)

I looked at all 7 at full resolution (copies in img/, ≤1600 px viewing copies in img/view/).

2a. What is right

2b. Item numbers are printed on the child's card

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.

2c. Oral Reading Fluency is not a passage any more

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.

2d. Size at phone distance

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.

2e. Smaller issues on the cards

2f. The bigger design question: one phone, two jobs

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.


3. The Listening Comprehension Flow

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.

3a. Meta constraints: all fine except one

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}.

3b. Design problems in the Flow


4. Coach-facing messages

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).


5. What is done well


6. What to do, in order

  1. Make the images arrive. Presign or download with credentials (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.
  2. Remove the item numbers from child-facing cards. Render ORF as a paragraph, and score English ORF on 60 words, not 68 tokens.
  3. Decide the child-card medium. Printed card plus phone as score sheet is recommended. If it stays on the phone, lay cards out for sideways viewing at roughly twice today's type size.
  4. Fix the two "read it out" instructions, and show accepted answers to the coach for every question.
  5. Reorder the Listening step (record first, then administer), keep the questions visible in chat, and fix the item-bank typos at the source.
  6. Move all coach strings to the strings catalog with Urdu, following the language-protocol skill.
  7. EGMA: make the child-facing number and pattern cards, or pull the 3 picture items until the pictures exist.
  8. Smaller fixes: - Discontinue copy per subtest. - A 60-second reminder. - A school font with a distinct I/l. - Cell padding for wide Urdu letters. - Backtick form for the Flow heading. - "Rumi" rather than "WhatsApp" in the caption.

Files

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.