Skip to content

feat(mcp): widget UX polish — i18n, colour semantics, CTAs, dead payload data, card alignment (#570) - #578

Merged
guillermoscript merged 1 commit into
masterfrom
feat/widget-ux-polish-570
Jul 27, 2026
Merged

feat(mcp): widget UX polish — i18n, colour semantics, CTAs, dead payload data, card alignment (#570)#578
guillermoscript merged 1 commit into
masterfrom
feat/widget-ux-polish-570

Conversation

@guillermoscript

Copy link
Copy Markdown
Owner

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. New resources/shared/i18n.tsx exposes useLang / useStrings / useFormat, driven by useWidget().locale (the BCP 47 tag the host supplies per SEP-1865) and timeZone. All 13 widgets with user-visible chrome now carry an en/es table, typed Record<Lang, T> so a missing Spanish string is a compile error rather than a runtime fallback. Every toLocale* call site is gone. practice-player had a hand-rolled navigator.language sniff 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) and progressColor (student-progress-roster) were the same function copied twice, both running amber → brand → green. They collapse into resources/shared/severity.tsx on 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 null and 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 draft caption gains the missing archived bucket so the parts sum to the 6 shown above them.

Not in the issue, fixed anyway: the at-risk tile's caption was the hardcoded string "active · 0 lessons done" — not derived from anything. The real rule (analytics.ts) is active enrollment + course has published lessons + zero completions, so the caption now says that. Flagging separately because it was wrong output, not polish. Related: that number counts enrollment pairs, not distinct students, while the tile is labelled "At-risk students". I left the label alone — changing what it counts is #568's territory — but the caption now discloses the unit.

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_catalog selected course_id, title, description, thumbnail_url, tags, created_at and never asked for one. It now joins product_coursesproducts and reports the cheapest active product per course (a course can be sold under several, so this aggregates rather than .single()-ing). Enroll/Access collapse into one verb, Not in plan becomes 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_lessons was dropped in the common case. Slight correction to the issue: it was rendered — inside the goals.length === 0 branch 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 worked

The demo tools' documented brand=ocean|sunset|forest argument themed nothing. src/tools/demo.ts computed the branding, named it in the output text, and never attached it to the result — BRANDING_META_KEY was an unused import. It now goes through widget({ metadata }), the same _meta sideband the real server uses.

I fixed it because I needed that channel: the headless preview harness supplies no host locale, so useWidget().locale falls back to "en" and the Spanish path would have had no evidence at all. The demo tools now take lang=en|es over the same sideband. Nothing in production sets it; the host stays the source of truth. Documented in docs/WIDGET_DEMO_DATA.md.

How to QA

No migration, no seed data, no login — this is all offline against committed fixtures.

cd mcp-server && npm install
MCP_DEMO_WIDGETS=1 npm run dev
# inspector: http://localhost:3000/inspector?autoConnect=http%3A%2F%2Flocalhost%3A3000%2Fmcp

Then, per item — every one has a named fixture:

  1. i18n — run lms_demo_school_overview with variant=default, then again with lang=es. The fixtures are Spanish, so lang=es is 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 %).
  2. Colourlms_demo_school_overview and lms_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 read 3 published · 2 draft · 1 archived and sum to 6.
  3. exam-readinessvariant=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.
  4. my-learningvariant=default. The next lesson is a filled primary button.
  5. course-catalogvariant=subscriber. Prices on the cards you cannot yet access, one verb on every CTA, Get access where Not in plan used to be a dead end, and no book-emoji blocks. variant=no-plan for the unsubscribed case.
  6. study-planvariant=default (i.e. with goals, where it used to be dropped). An "Up next" block with an Open → per lesson.
  7. Alignmentlms_demo_course_dashboard variant=default ("Diseño de APIs REST" has no description) and lms_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.
  8. The drive-by — any demo tool with brand=forest. It should render green. On master it renders violet.

Whole set at once, both themes:

./scripts/shoot-demo-widgets.sh dark     # → mcp-server/demo-shots/dark/
./scripts/shoot-demo-widgets.sh light

Screenshots / GIF

Posted as a comment below — before/after for each item, dark and light, plus the Spanish render.

Checklist

  • npm run typecheck and npm run test:unit pass — in mcp-server: tsc --noEmit clean, vitest run 39/39
  • npm run build passes — mcp-use build, 18 widgets built, type check passed
  • Every new tenant-scoped query filters by tenant_id (or the table genuinely has no such column) — the new product_courses query filters tenant_id and is explicit about products.status = 'active' on top of the RLS policy that already enforces it
  • Tested with every relevant role (student / teacher / admin) — see below
  • Loading and error states handled — pending branches translated too; the price lookup degrades to no price rather than failing the catalog
  • New UI strings added to both messages/en.json and messages/es.jsonn/a, and worth being explicit: MCP widgets render in a host iframe and never load next-intl, which is exactly why they had no i18n. Their strings live in per-widget tables next to the component.
  • Migration — none.

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_catalog against a live tenant with real products rows — 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" is TO anon, authenticated USING (status = 'active'), and product_courses is USING (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

🤖 Generated with Claude Code

https://claude.ai/code/session_01Bs69mfRZXog8KPM1n626RA

…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
@guillermoscript guillermoscript added enhancement New feature or request Sub-task mcp-server MCP server (mcp-server/) tools, widgets, auth labels Jul 27, 2026
@guillermoscript guillermoscript self-assigned this Jul 27, 2026
@guillermoscript

Copy link
Copy Markdown
Owner Author

Visual evidence

Before/after per item, from the committed fixtures — every one is reproducible offline in under a minute with MCP_DEMO_WIDGETS=1 npm run dev and the named variant. Left is master, right is this branch, same fixture, same theme.

2. Colour semantics — school_overview:default

school-overview before and after

Three separate things in one shot. The bars stop using brand violet as the value between amber and green: 68% and 47% are amber, 22% is red, 91% stays green. 3 published · 2 draft becomes 3 published · 2 draft · 1 archived, which now sums to the 6 above it. The at-risk caption stops claiming active · 0 lessons done — a string that was hardcoded, not computed — and states the rule the number actually comes from.

Two smaller ones visible here too: the KPI accents follow a rule now (54% and 76% are verdicts and get colour; the counts are facts and stay neutral), and "Diseño de APIs REST" — a draft with 0 published lessons — reads on a grey bar instead of a red 0%, because having nothing to complete is not the same as having completed nothing.

3. exam-readiness — exam_readiness:default

exam-readiness before and after

Before, the topics run 88, 79, 44, 35, 62, 12 — payload order, so the weakest topic in the course is the last thing you reach, and all six "Practice this" buttons shout equally. After: 12, 35, 44, 62, 79, 88, with the 12% topic flagged and outlined, and the mastered 88 dropped to a quiet link so it stops competing. The header gains the countdown (in 8 days) instead of making the reader subtract dates.

5. course-catalog — course_catalog:subscriber

course-catalog before and after

The six identical grey 📖 blocks are gone, which is most of the height back. Prices appear on the cards where price is still a decision. Enroll and Access become one verb. Not in plan — grey text on the only cards where the student still had something to decide — becomes Get access; the card with no active product keeps an honest "not in your plan" with no CTA, because there is genuinely nothing to sell.

This one also shows item 7: the tag rows and the footers now sit on one line across each row.

4. my-learning — my_learning:default

my-learning before and after

6. study-plan — study_plan:default

study-plan before and after

Note this is the default variant — the one with goals. context.next_lessons was only ever rendered in the no-goals branch, so on the left the server sent it and the widget dropped it.

7. Card alignment — course_dashboard:default and gamification_profile:default

course-dashboard before and after

gamification-profile before and after

"Diseño de APIs REST" has no description; "Buena gente" has neither a description nor a tier badge. Their neighbours' tag rows, badges and footers no longer move to accommodate them.


The Spanish path

The thing the issue is actually about: the fixtures have always been Spanish, and the chrome around them was not. These are the same fixtures with lang=es.

school-overview in Spanish

exam-readiness in Spanish

course-catalog in Spanish

Dates, numbers and currency follow the locale, not just the words — 4 ago 2026, dentro de 8 días, 1.043, 89,00 US$, and 54 % with the space Spanish typography wants. Course titles, lesson titles and tags are untouched: they are already in the school's language.

The drive-by

brand=forest now themes

lms_demo_my_learning brand=forest. On master this renders the platform violet, because the demo tool computed the branding and never attached it. Same command on this branch. It is one line, but it is documented in WIDGET_DEMO_DATA.md and in #571 as a working feature, and it is the channel the lang argument above rides on.

What is not proven here

These are fixtures, so they prove rendering, not the database. The one change in this PR that fixtures cannot cover is the price join in lms_browse_catalog (product_coursesproducts). The RLS allows it and it fails soft, but a reviewer with a seeded tenant should confirm a real price shows up on a real catalog. Called out in the PR body too.

@guillermoscript
guillermoscript marked this pull request as ready for review July 27, 2026 21:13
@guillermoscript
guillermoscript merged commit b1134e7 into master Jul 27, 2026
2 checks passed
@guillermoscript

Copy link
Copy Markdown
Owner Author

Merged into master as b1134e7.

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 context.next_lessons in the case you actually see; and the three card grids share a row grid so optional blocks collapse without dragging their neighbours out of line.

Two things that were not in the issue and are worth remembering:

  • The at-risk KPI caption was the hardcoded string "active · 0 lessons done" — not computed from anything. It now states the rule the number really comes from. The number still counts enrollment pairs while the tile is labelled "students"; that is left for widgets: missing data rendered as real data (raw user ids, ungraded exams as "—", filter-blaming empty state) #568.
  • The demo tools' brand=ocean|sunset|forest argument had never worked — the branding was computed and then dropped instead of being attached to _meta. Fixed, because the same sideband now carries the lang= argument the offline harness needs to render the Spanish path at all.

Still unproven by this PR, and stated in the body too: the price join in lms_browse_catalog was verified against fixtures, not a seeded tenant. It fails soft (a query error leaves cards priceless rather than breaking the catalog), but the first person with real products rows should confirm a price actually appears.

guillermoscript added a commit that referenced this pull request Jul 28, 2026
#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
guillermoscript added a commit that referenced this pull request Jul 28, 2026
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
guillermoscript added a commit that referenced this pull request Jul 28, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request mcp-server MCP server (mcp-server/) tools, widgets, auth Sub-task

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MCP widget UX polish: i18n, colour semantics, CTAs, unused payload data, card alignment

1 participant