feat(mcp): widget UX polish — i18n, colour semantics, CTAs, dead payload data, card alignment (#570) - #578
Conversation
…oad data, card alignment (#570) Seven independent polish items from the fixture render pass. None of these were broken output; each one worked and read badly. 1. i18n. Every string a widget owned was hardcoded English and every date was formatted with an implicit en-US, rendered above Spanish course content. New `resources/shared/i18n.tsx` gives `useLang` / `useStrings` / `useFormat`, driven by `useWidget().locale` (the BCP 47 tag the host supplies) and `timeZone`. All 13 widgets with user-visible chrome now carry an en/es table typed so a missing Spanish string is a compile error, and every `toLocaleDateString(undefined, …)` call site is gone. practice-player's hand-rolled `navigator.language` sniff is replaced by the shared hook. Only widget-owned strings are translated; course titles, lesson titles and tags come from the database in the school's language and stay verbatim. 2. Colour semantics. `completionColor` and `progressColor` were the same percent-to-colour function copied into two widgets, both running amber -> brand -> green, which made the school's brand colour a mid value on a severity scale. They collapse into `resources/shared/severity.tsx` on a red/amber/green ramp with brand removed, per the rule already stated in the epic. The two copies had drifted (0% returned zinc in one and red in the other); "no data" is now `null` and distinct from a real zero, so a draft course with nothing to complete reads neutral instead of failing. KPI tiles gain a stated rule — colour marks a verdict, counts stay neutral — and the "3 published · 2 draft" caption gains the missing archived bucket so the parts sum to the total above them. Also replaces a hardcoded "active · 0 lessons done" caption with the rule the number is really computed from. 3. exam-readiness sorts topics by mastery ascending, so the weakest is first rather than last; marks it "Focus here first"; drops the practice CTA to a quiet link on already-mastered topics; and renders days-until-exam from `exam.exam_date`, counted in calendar days. 4. my-learning's next lesson becomes a real primary button that opens the lesson, instead of a grey pill in the card footer. 5. course-catalog can convert. `lms_browse_catalog` never asked the database for a price, so there was none to render: it now joins `product_courses` to `products` and reports the cheapest active product per course (a course can be sold under several). `Enroll` and `Access` collapse into one verb, `Not in plan` becomes a real path instead of grey text, and the 96px book emoji placeholder is dropped when there is no thumbnail. 6. study-plan renders `context.next_lessons`, which the server has always sent and the widget only showed in the empty state — so it was dropped in the common case. 7. Card alignment. course-dashboard, course-catalog and gamification-profile give their cards a shared row grid (`grid-rows-subgrid`), so a null description or an absent tag row collapses without pushing the tags and footers out of line with the neighbouring cards. Drive-by, because this change needed the same channel: the demo tools' documented `brand=ocean|sunset|forest` argument never worked. `src/tools/demo.ts` computed the branding and named it in the output text but never attached it to the result, so every "branded" preview rendered the platform violet. It now goes through `widget({ metadata })`. The same sideband carries a new `lang` argument, so the offline harness can render the Spanish path it otherwise could not show. Verified: tsc clean, `npm run build` (18 widgets), 39/39 vitest, and every affected fixture re-shot in dark and light and in both languages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bs69mfRZXog8KPM1n626RA
Visual evidenceBefore/after per item, from the committed fixtures — every one is reproducible offline in under a minute with 2. Colour semantics —
|
|
Merged into All seven items from #570 shipped: widget-owned strings and every date/number now follow the host locale in en/es; the brand colour is out of the red→green status ramp (and the two drifted copies of that ramp are one shared helper); exam-readiness sorts weakest-first with a countdown; my-learning's next lesson is a real button; the catalog shows a price and an upgrade path on one vocabulary; study-plan renders Two things that were not in the issue and are worth remembering:
Still unproven by this PR, and stated in the body too: the price join in |
#578 (widget i18n/colour semantics) and #575 (missing-data display) both rewrote submission-grader/widget.tsx, so every hunk of this branch conflicted. Resolved by resetting the file to master and re-applying the #567 fix on top of the new structure rather than reconciling markers. What changed in the re-application: - The three new affordances are now translated. "Not graded", "Teacher adjusted", the low-confidence pill and the ungraded-points note all have en/es entries in this widget's STRINGS table, matching #578's convention; a missing Spanish string is a type error. - The em dash for an ungraded question is no longer hand-written. fmt.number() from shared/i18n.tsx already renders null as an em dash, so dropping the `?? 0` is now the entire fix at that call site and the dash follows the reader's locale like every other number. - Points and confidence go through fmt.number/fmt.percent, so es renders "confianza: 41 %" rather than a hardcoded English percentage. master's own shared/severity.ts landed the same rule this issue is about — "a draft course with no lessons has nothing to complete (neutral); a student who has completed nothing is at 0% and that is the worst case" — and #575 applied it to the roster while leaving `points_earned ?? 0` in this widget untouched. The re-applied fix is that doctrine reaching the per-question list, and isUngraded() now cites it. Kept from master unchanged: the inline "(82% confidence)" suffix for a confident score, so only a guess is promoted to a pill. Verified after the merge: mcp-server tsc --noEmit clean (the 4 pre-existing useCallTool errors this branch flagged are gone — master fixed them), 66/66 mcp-server tests, and the demo fixture re-shot in dark, light and es. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Zt1kZKdcWAcsiJdBgnX3D
master's #578 rewrote this widget with i18n, so the PR's original screenshots no longer show the code under review. Re-shot from the demo fixture after the merge, plus a Spanish render — the three new affordances are translated now, and es is the path the old shots could not have covered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Zt1kZKdcWAcsiJdBgnX3D
…s, invisible overrides, decorative confidence (#567) (#574) * fix(mcp): make submission-grader tell the truth about what is graded (#567) The per-question review list in the submission-grader widget misreported how much of a submission had actually been graded — the one thing a teacher opens it to find out. - An ungraded question (`points_earned: null`) rendered as `0/15 pts`, making "nobody has looked at this yet" indistinguishable from "the student got it wrong", and contradicting the `4/5 graded` summary a few hundred pixels above. It now renders as `—/15 pts` alongside a "Not graded" pill, and the `?? 0` coercion is gone. - The grade panel now states how many points sit in ungraded questions, so the `60 / 100` figure can no longer read as "the student lost 40 points" when 15 of those points have simply never been graded. - `is_overridden` arrived in the payload and was never rendered, so a teacher could not see which scores they had already corrected. Overridden rows now carry a "Teacher adjusted" pill. - AI confidence was plain grey text appended to a label, so 41% and 82% looked identical. Confidence below 60% now renders as an amber "review this" pill and tints the question's border, while high confidence stays quiet. The server side needed no change: `lms_get_submission` already excludes null scores from `total_points_earned` and `graded_count` (analytics.ts:382-385). The misleading part was the denominator spanning ungraded questions, which is a presentation concern and is handled in the widget. Verified against the `lms_demo_submission_grader` default fixture (ungraded Q5, overridden Q4, confidences 0.82 and 0.41) in both light and dark themes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Zt1kZKdcWAcsiJdBgnX3D * docs(mcp): before/after shots of the submission-grader grading states (#567) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Zt1kZKdcWAcsiJdBgnX3D * docs(qa): re-shoot #567 grading states on the merged widget master's #578 rewrote this widget with i18n, so the PR's original screenshots no longer show the code under review. Re-shot from the demo fixture after the merge, plus a Spanish render — the three new affordances are translated now, and es is the path the old shots could not have covered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Zt1kZKdcWAcsiJdBgnX3D --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>











What
The seven polish items from #570, plus one bug found in the tooling they needed. None of these were broken output — each one worked and read badly.
Closes #570
Why
Per item, and what turned out to be true rather than what the issue assumed:
1. Widgets were English-only next to Spanish content. Every string a widget owned was hardcoded, and every date went through
toLocaleDateString(undefined, …), which is the reader's browser locale rather than the school's. Newresources/shared/i18n.tsxexposesuseLang/useStrings/useFormat, driven byuseWidget().locale(the BCP 47 tag the host supplies per SEP-1865) andtimeZone. All 13 widgets with user-visible chrome now carry an en/es table, typedRecord<Lang, T>so a missing Spanish string is a compile error rather than a runtime fallback. EverytoLocale*call site is gone.practice-playerhad a hand-rollednavigator.languagesniff for one string; it now uses the shared hook.Only widget-owned strings are translated. Course titles, lesson titles, student names and tags come out of the database in the school's own language and are rendered verbatim.
2. Brand purple was a mid value on a red→green scale.
completionColor(school-overview) andprogressColor(student-progress-roster) were the same function copied twice, both running amber → brand → green. They collapse intoresources/shared/severity.tsxon red/amber/green with brand removed — the rule #571 already states ("Accents theme, status colours deliberately do not").The two copies had already drifted: at exactly 0% one returned zinc and the other red. That forced the real question, so "no data" is now
nulland distinct from a real zero — a draft course with nothing to complete reads neutral instead of being painted as a failing one.Also in scope, from the same item: KPI tiles get a stated rule (colour marks a verdict; counts of courses and students are facts and stay neutral), and the
3 published · 2 draftcaption gains the missing archived bucket so the parts sum to the 6 shown above them.3. exam-readiness buried the thing to act on. Topics render weakest-first, the weakest is marked, the practice CTA drops to a quiet link on already-mastered topics so it stops competing, and days-until-exam is rendered from
exam.exam_date(counted in calendar days, so a 09:00 exam tomorrow reads "tomorrow", not "in 0 days").4. my-learning's next lesson is now a button. It is the card's actual job, and it was a grey pill in the footer.
5. course-catalog had no price to render. Not "not displayed" — not present.
lms_browse_catalogselectedcourse_id, title, description, thumbnail_url, tags, created_atand never asked for one. It now joinsproduct_courses→productsand reports the cheapest active product per course (a course can be sold under several, so this aggregates rather than.single()-ing).Enroll/Accesscollapse into one verb,Not in planbecomes a real path instead of a grey dead end, and the 96px📖placeholder is dropped when there is no thumbnail rather than reserving space for nothing.6. study-plan's
next_lessonswas dropped in the common case. Slight correction to the issue: it was rendered — inside thegoals.length === 0branch only. So the moment a student had a plan at all, the payload was fetched and thrown away. Same fix, aimed at the case you actually see.7. Null fields broke card-row alignment. course-dashboard, course-catalog and gamification-profile give their cards a shared row grid via
grid-rows-subgrid, so a null description or an absent tag row collapses without pushing the tags and footers out of line with the neighbouring cards.Drive-by:
brand=never workedThe demo tools' documented
brand=ocean|sunset|forestargument themed nothing.src/tools/demo.tscomputed the branding, named it in the output text, and never attached it to the result —BRANDING_META_KEYwas an unused import. It now goes throughwidget({ metadata }), the same_metasideband the real server uses.I fixed it because I needed that channel: the headless preview harness supplies no host locale, so
useWidget().localefalls back to"en"and the Spanish path would have had no evidence at all. The demo tools now takelang=en|esover the same sideband. Nothing in production sets it; the host stays the source of truth. Documented indocs/WIDGET_DEMO_DATA.md.How to QA
No migration, no seed data, no login — this is all offline against committed fixtures.
Then, per item — every one has a named fixture:
lms_demo_school_overviewwithvariant=default, then again withlang=es. The fixtures are Spanish, solang=esis what a real school looks like and the default is the mismatch the issue is about. Dates and numbers should follow (4 ago 2026,54 %).lms_demo_school_overviewandlms_demo_student_progress_roster,variant=default. No violet in any bar; 68% is amber, 22% is red, 91% is green. Drafts with 0 lessons are grey with—, not red 0%. The Courses tile should read3 published · 2 draft · 1 archivedand sum to 6.variant=default. Topics run 12 → 88, the 12% "Compilador de React" is first and flagged, "Server components" (88) has a quiet link, and the header shows a countdown.variant=default. The next lesson is a filled primary button.variant=subscriber. Prices on the cards you cannot yet access, one verb on every CTA,Get accesswhereNot in planused to be a dead end, and no book-emoji blocks.variant=no-planfor the unsubscribed case.variant=default(i.e. with goals, where it used to be dropped). An "Up next" block with anOpen →per lesson.lms_demo_course_dashboard variant=default("Diseño de APIs REST" has no description) andlms_demo_gamification_profile variant=default("Buena gente" has no description and no tier). Tag rows and footers should sit on one line across each row.brand=forest. It should render green. Onmasterit renders violet.Whole set at once, both themes:
./scripts/shoot-demo-widgets.sh dark # → mcp-server/demo-shots/dark/ ./scripts/shoot-demo-widgets.sh lightScreenshots / GIF
Posted as a comment below — before/after for each item, dark and light, plus the Spanish render.
Checklist
npm run typecheckandnpm run test:unitpass — inmcp-server:tsc --noEmitclean,vitest run39/39npm run buildpasses —mcp-use build, 18 widgets built, type check passedtenant_id(or the table genuinely has no such column) — the newproduct_coursesquery filterstenant_idand is explicit aboutproducts.status = 'active'on top of the RLS policy that already enforces itmessages/en.jsonandmessages/es.json— n/a, and worth being explicit: MCP widgets render in a host iframe and never loadnext-intl, which is exactly why they had no i18n. Their strings live in per-widget tables next to the component.On the role checkbox, honestly
The widgets are covered per role by their fixtures, which is what this issue's "done when" asks for, and the tool-policy split is unchanged. What I did not do is exercise
lms_browse_catalogagainst a live tenant with realproductsrows — the price join is the one change here that touches a database query, and offline fixtures cannot prove it. The RLS policies allow it ("Anyone can view active products"isTO anon, authenticated USING (status = 'active'), andproduct_coursesisUSING (true)), and it fails soft — a query error leaves cards priceless rather than breaking the catalog. But a reviewer with a seeded tenant should confirm a real price appears.Notes for the reviewer
submission-graderalso has changes in fix(mcp): submission-grader misreports what is graded — ungraded zeros, invisible overrides, decorative confidence (#567) #574 (issue submission-grader: ungraded questions render as 0, overrides are invisible, AI confidence is decoration #567, in flight). I only touched its strings and date formatting, but expect a conflict there for whichever lands second.my-learning's progress bars are still brand-coloured, deliberately. That widget never had a red→green ramp — it is brand for in-progress and green for complete, a two-state accent rather than a severity scale, so item 2's rule does not apply to it.lang_metaoverride is a preview affordance, not a second config surface. Nothing in production sets it, and there is no locale column anywhere in the schema to set it from.🤖 Generated with Claude Code
https://claude.ai/code/session_01Bs69mfRZXog8KPM1n626RA