Skip to content

Commit 8d7c9d1

Browse files
feat(mcp): widget UX polish — i18n, colour semantics, CTAs, dead payload 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
1 parent a6c88d3 commit 8d7c9d1

20 files changed

Lines changed: 1809 additions & 493 deletions

File tree

mcp-server/docs/WIDGET_DEMO_DATA.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,37 @@ Two constraints worth knowing before you touch this:
7878
Preview any widget under a fake school with the `brand` argument on the demo
7979
tools: `none` (default), `ocean`, `sunset`, `forest`.
8080

81+
> Until #570 this argument did nothing. `src/tools/demo.ts` computed the brand
82+
> and named it in the output text but never attached it to the result's
83+
> `_meta`, so every "branded" preview rendered the platform violet. It now
84+
> passes it through `widget({ metadata })`, the same sideband the real server
85+
> uses.
86+
87+
## Language
88+
89+
Widget chrome is en/es. The language comes from the host —
90+
`useWidget().locale`, a BCP 47 tag (SEP-1865 `HostContext.locale`) — and
91+
`resources/shared/i18n.tsx` turns it into a string table plus `Intl`
92+
formatters. Only strings the widget owns are translated; course titles, lesson
93+
titles and tags come out of the database in the school's own language and are
94+
rendered verbatim.
95+
96+
The preview harness supplies **no** host locale, so everything would otherwise
97+
render at the `"en"` default and the Spanish half would never be reviewable.
98+
Pass `lang=es` (or `en`) to any demo tool to pin it:
99+
100+
```bash
101+
npx mcp-use client screenshot --mcp http://localhost:3000/mcp \
102+
--tool lms_demo_school_overview variant=default lang=es \
103+
--width 1100 --height 800 --theme dark --output es.png
104+
```
105+
106+
That rides the same `_meta` channel as branding (`lms/locale`, see
107+
`src/locale.ts`). Nothing in production sets it — the host stays the source of
108+
truth. The fixtures are deliberately Spanish, so `lang=es` is the combination
109+
that matches a real school, and `lang=en` next to Spanish content is the
110+
mismatch #570 was filed about.
111+
81112
## Editing fixtures
82113

83114
Fixtures must satisfy each widget's own `propsSchema`. When you change a widget

mcp-server/resources/course-catalog/widget.tsx

Lines changed: 159 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
type WidgetMetadata,
88
} from "mcp-use/react";
99
import { Brand } from "../shared/branding";
10+
import { useFormat, useStrings } from "../shared/i18n";
1011
import { z } from "zod";
1112

1213
// ── Schema ──────────────────────────────────────────────────────────────────
@@ -21,6 +22,10 @@ const courseSchema = z.object({
2122
enrolled: z.boolean(),
2223
has_access: z.boolean(),
2324
covered_by_plan: z.boolean(),
25+
// Cheapest active product covering this course. `null` = not individually
26+
// for sale. Optional so a payload from an older server still validates.
27+
price: z.number().nullable().optional(),
28+
currency: z.string().nullable().optional(),
2429
});
2530

2631
const propsSchema = z.object({
@@ -42,6 +47,44 @@ export const widgetMetadata: WidgetMetadata = {
4247

4348
type Props = z.infer<typeof propsSchema>;
4449

50+
// ── Strings ──────────────────────────────────────────────────────────────────
51+
52+
const STRINGS = {
53+
en: {
54+
loading: "Browsing catalog…",
55+
title: "Course Catalog",
56+
summary: (n: number, s: string) => `${s} published course${n === 1 ? "" : "s"}`,
57+
subscriptionActive: " · subscription active",
58+
empty: "No published courses found",
59+
lessons: (n: number, s: string) => `${s} lesson${n === 1 ? "" : "s"}`,
60+
enrolled: "✓ Enrolled",
61+
// One verb for "get into this course", whichever entitlement gets you in.
62+
start: "Start course",
63+
enrolling: "Enrolling…",
64+
getAccess: "Get access",
65+
notInPlan: "Not in your plan",
66+
free: "Free",
67+
enrollFailed: (title: string) => `Could not enroll in "${title}". Please try again.`,
68+
},
69+
es: {
70+
loading: "Explorando el catálogo…",
71+
title: "Catálogo de cursos",
72+
summary: (n: number, s: string) =>
73+
`${s} ${n === 1 ? "curso publicado" : "cursos publicados"}`,
74+
subscriptionActive: " · suscripción activa",
75+
empty: "No se encontraron cursos publicados",
76+
lessons: (n: number, s: string) => `${s} ${n === 1 ? "lección" : "lecciones"}`,
77+
enrolled: "✓ Inscrito",
78+
start: "Empezar curso",
79+
enrolling: "Inscribiendo…",
80+
getAccess: "Obtener acceso",
81+
notInPlan: "No incluido en tu plan",
82+
free: "Gratis",
83+
enrollFailed: (title: string) =>
84+
`No se pudo inscribir en "${title}". Inténtalo de nuevo.`,
85+
},
86+
};
87+
4588
// ── Helpers ──────────────────────────────────────────────────────────────────
4689

4790
function normalizeTags(tags: string[] | string | null): string[] {
@@ -72,6 +115,8 @@ export default function CourseCatalog() {
72115
const [enrolledIds, setEnrolledIds] = useState<Set<number>>(new Set());
73116
const [enrollError, setEnrollError] = useState<string | null>(null);
74117
const dark = theme === "dark";
118+
const t = useStrings(STRINGS);
119+
const fmt = useFormat();
75120

76121
if (isPending) {
77122
return (
@@ -80,7 +125,7 @@ export default function CourseCatalog() {
80125
<div className={dark ? "dark" : ""}>
81126
<div className="bg-zinc-50 p-10 text-center font-sans text-zinc-400 dark:bg-zinc-950 dark:text-zinc-500">
82127
<div className="mx-auto mb-3 size-9 animate-spin rounded-full border-[3px] border-zinc-200 border-t-[var(--brand-600)] dark:border-zinc-800 dark:border-t-[var(--brand-400)]" />
83-
<p className="m-0 text-sm">Browsing catalog…</p>
128+
<p className="m-0 text-sm">{t.loading}</p>
84129
</div>
85130
</div>
86131
</McpUseProvider>
@@ -102,7 +147,7 @@ export default function CourseCatalog() {
102147
);
103148
},
104149
onError: () =>
105-
setEnrollError(`Could not enroll in "${title}". Please try again.`),
150+
setEnrollError(t.enrollFailed(title)),
106151
onSettled: () => {
107152
setPendingIds((prev) => {
108153
const next = new Set(prev);
@@ -121,11 +166,11 @@ export default function CourseCatalog() {
121166
<div className="mx-auto max-w-[820px] bg-zinc-50 p-6 font-sans dark:bg-zinc-950">
122167
<div className="mb-4.5 flex flex-wrap items-baseline justify-between gap-2">
123168
<h1 className="m-0 text-[22px] font-bold tracking-tight text-zinc-900 dark:text-zinc-100">
124-
Course Catalog
169+
{t.title}
125170
</h1>
126171
<span className="text-[13px] text-zinc-500 dark:text-zinc-400">
127-
{total} published course{total === 1 ? "" : "s"}
128-
{has_subscription ? " · subscription active" : ""}
172+
{t.summary(total, fmt.number(total))}
173+
{has_subscription ? t.subscriptionActive : ""}
129174
</span>
130175
</div>
131176

@@ -142,44 +187,66 @@ export default function CourseCatalog() {
142187
<div className="rounded-xl border border-zinc-200 bg-white p-10 text-center dark:border-zinc-800 dark:bg-zinc-900">
143188
<div className="mb-2 text-[32px]">📚</div>
144189
<p className="m-0 text-[15px] font-semibold text-zinc-900 dark:text-zinc-100">
145-
No published courses found
190+
{t.empty}
146191
</p>
147192
</div>
148193
) : (
149-
<div className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-3.5">
194+
/*
195+
Four named rows — media, heading, tags, footer — declared on the
196+
grid and adopted by every card via `grid-rows-subgrid`. Optional
197+
blocks then collapse *in step*: a card with no description or no
198+
tags leaves a short row rather than a hole, and the tag rows and
199+
footers line up across the row instead of floating to wherever
200+
each card's own content happened to end. When no card in a row
201+
has a thumbnail the media row collapses to nothing, which is why
202+
the placeholder below can simply not be rendered.
203+
*/
204+
<div className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] grid-rows-[auto_auto_auto_auto] gap-3.5">
150205
{courses.map((course) => {
151206
const tags = normalizeTags(course.tags).slice(0, 3);
207+
const owned = course.enrolled || enrolledIds.has(course.id);
208+
const canEnter = course.covered_by_plan || course.has_access;
209+
const price = course.price ?? null;
210+
const priceLabel =
211+
price === null
212+
? null
213+
: price === 0
214+
? t.free
215+
: fmt.currency(price, course.currency);
216+
152217
return (
153218
<div
154219
key={course.id}
155-
className="flex flex-col overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900"
220+
className="row-span-4 grid grid-rows-subgrid overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900"
156221
>
157-
{/* Thumbnail */}
158-
<div
159-
className="flex h-24 items-center justify-center bg-zinc-100 bg-cover bg-center text-[28px] dark:bg-zinc-800"
160-
style={
161-
course.thumbnail_url
162-
? { backgroundImage: `url(${course.thumbnail_url})` }
163-
: undefined
164-
}
165-
>
166-
{!course.thumbnail_url && "📖"}
167-
</div>
222+
{/*
223+
A 96px grey block with a book emoji on every card carried
224+
no information and cost more vertical space than the title
225+
it sat above. With no image there is simply no media row.
226+
*/}
227+
{course.thumbnail_url ? (
228+
<div
229+
className="h-24 bg-zinc-100 bg-cover bg-center dark:bg-zinc-800"
230+
style={{ backgroundImage: `url(${course.thumbnail_url})` }}
231+
/>
232+
) : (
233+
<div />
234+
)}
168235

169-
<div className="flex flex-1 flex-col gap-2 p-3.5">
170-
<div>
171-
<div className="text-[14.5px] leading-[1.3] font-semibold text-zinc-900 dark:text-zinc-100">
172-
{course.title}
173-
</div>
174-
{course.description && (
175-
<div className="mt-1 line-clamp-2 text-xs leading-[1.45] text-zinc-400 dark:text-zinc-500">
176-
{course.description}
177-
</div>
178-
)}
236+
<div className="px-3.5 pt-3.5">
237+
<div className="text-[14.5px] leading-[1.3] font-semibold text-zinc-900 dark:text-zinc-100">
238+
{course.title}
179239
</div>
240+
{course.description && (
241+
<div className="mt-1 line-clamp-2 text-xs leading-[1.45] text-zinc-400 dark:text-zinc-500">
242+
{course.description}
243+
</div>
244+
)}
245+
</div>
180246

247+
<div className="px-3.5">
181248
{tags.length > 0 && (
182-
<div className="flex flex-wrap gap-[5px]">
249+
<div className="flex flex-wrap gap-[5px] pt-2">
183250
{tags.map((tag) => (
184251
<span
185252
key={tag}
@@ -190,34 +257,71 @@ export default function CourseCatalog() {
190257
))}
191258
</div>
192259
)}
260+
</div>
193261

194-
<div className="mt-auto flex items-center justify-between gap-2">
195-
<span className="text-[11.5px] text-zinc-400 dark:text-zinc-500">
196-
{course.lesson_count} lesson
197-
{course.lesson_count === 1 ? "" : "s"}
198-
</span>
199-
{course.enrolled || enrolledIds.has(course.id) ? (
200-
<span className="rounded-full bg-green-100 px-[9px] py-[3px] text-[11px] font-bold text-green-600 dark:bg-green-900 dark:text-green-400">
201-
✓ Enrolled
202-
</span>
203-
) : course.covered_by_plan ? (
204-
<button
205-
onClick={() => handleEnroll(course.id, course.title)}
206-
disabled={pendingIds.has(course.id)}
207-
className="cursor-pointer rounded-full border-none bg-[var(--brand-600)] px-2.5 py-[3px] text-[11px] font-bold text-white disabled:cursor-default disabled:opacity-60 dark:bg-[var(--brand-400)]"
208-
>
209-
{pendingIds.has(course.id) ? "Enrolling…" : "Enroll"}
210-
</button>
211-
) : course.has_access ? (
212-
<span className="rounded-full bg-[var(--brand-100)] px-[9px] py-[3px] text-[11px] font-bold text-[var(--brand-600)] dark:bg-[var(--brand-950)] dark:text-[var(--brand-400)]">
213-
Access
214-
</span>
215-
) : (
216-
<span className="text-[11px] text-zinc-400 dark:text-zinc-500">
217-
Not in plan
262+
<div className="flex items-center justify-between gap-2 px-3.5 pt-2 pb-3.5">
263+
<span className="min-w-0 text-[11.5px] text-zinc-400 dark:text-zinc-500">
264+
{t.lessons(course.lesson_count, fmt.number(course.lesson_count))}
265+
{/* Price only where it changes a decision — once the
266+
student is in, what it would have cost is noise. */}
267+
{!owned && !canEnter && priceLabel && (
268+
<span className="ml-1.5 font-semibold text-zinc-900 dark:text-zinc-100">
269+
· {priceLabel}
218270
</span>
219271
)}
220-
</div>
272+
</span>
273+
274+
{owned ? (
275+
<span className="shrink-0 rounded-full bg-green-100 px-[9px] py-[3px] text-[11px] font-bold text-green-600 dark:bg-green-900 dark:text-green-400">
276+
{t.enrolled}
277+
</span>
278+
) : course.covered_by_plan ? (
279+
<button
280+
onClick={() => handleEnroll(course.id, course.title)}
281+
disabled={pendingIds.has(course.id)}
282+
className="shrink-0 cursor-pointer rounded-full border-none bg-[var(--brand-600)] px-2.5 py-[3px] text-[11px] font-bold text-white disabled:cursor-default disabled:opacity-60 dark:bg-[var(--brand-400)] dark:text-zinc-950"
283+
>
284+
{pendingIds.has(course.id) ? t.enrolling : t.start}
285+
</button>
286+
) : course.has_access ? (
287+
/*
288+
Already entitled but not enrolled — `lms_enroll_in_course`
289+
only accepts plan-covered courses, so this opens the
290+
course instead of enrolling. Different call, same act
291+
as far as the student is concerned, so the same label.
292+
*/
293+
<button
294+
onClick={() =>
295+
sendFollowUpMessage(
296+
`Open the course "${course.title}" with lms_get_course_content so I can start it.`
297+
)
298+
}
299+
className="shrink-0 cursor-pointer rounded-full border-none bg-[var(--brand-600)] px-2.5 py-[3px] text-[11px] font-bold text-white dark:bg-[var(--brand-400)] dark:text-zinc-950"
300+
>
301+
{t.start}
302+
</button>
303+
) : priceLabel ? (
304+
/*
305+
The old dead end: "Not in plan" in grey, on the only
306+
cards where the student still had a decision to make.
307+
Purchases complete in the app, so this hands off to the
308+
assistant rather than pretending to check out here.
309+
*/
310+
<button
311+
onClick={() =>
312+
sendFollowUpMessage(
313+
`I want access to the course "${course.title}". What are my options — can I buy it or upgrade my plan?`
314+
)
315+
}
316+
className="shrink-0 cursor-pointer rounded-full border border-[var(--brand-600)] bg-transparent px-2.5 py-[3px] text-[11px] font-bold text-[var(--brand-600)] dark:border-[var(--brand-400)] dark:text-[var(--brand-400)]"
317+
>
318+
{t.getAccess}
319+
</button>
320+
) : (
321+
<span className="shrink-0 text-[11px] text-zinc-400 dark:text-zinc-500">
322+
{t.notInPlan}
323+
</span>
324+
)}
221325
</div>
222326
</div>
223327
);

0 commit comments

Comments
 (0)