From b494d529c31e2d23d8da7fb573e95900619213fc Mon Sep 17 00:00:00 2001 From: Guillermo Marin <52298929+guillermoscript@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:47:53 +0200 Subject: [PATCH 1/2] feat(mcp): widget preview fixtures + tenant branding for widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things the MCP widgets were missing: a way to look at them, and the school's own colours. **Preview mode.** Widget props came only from live Supabase reads, so seeing a widget needed a seeded DB, an OAuth login, and a row that happened to hit the branch you cared about — and edge states (empty lists, locked lessons, null scores, unpublished pages) were near-impossible to produce on demand. `MCP_DEMO_WIDGETS=1` registers one `lms_demo_` tool per widget serving hand-written fixtures for all 18, 44 variants covering the awkward cases. The flag is hard-gated on a non-production NODE_ENV; with it unset nothing registers, OAuth is installed as before and unauthenticated /mcp still 401s. Fixtures are also now the only committed example of each widget's payload, so a propsSchema change has to update them. **Tenant branding.** Widgets hardcoded violet while the app themes off `tenants.primary_color` (components/tenant/tenant-css-vars-server.tsx). The server now attaches branding to the tool result's `_meta` centrally in installToolGuards — no handler changes — and the widget derives a `--brand-50..950` ramp from that one colour with color-mix(). With no tenant colour it emits Tailwind's violet ramp verbatim, so unbranded schools are pixel-identical to before. The ramp is CSS custom properties plus arbitrary-value utilities (`bg-[var(--brand-600)]`) rather than a Tailwind theme colour because mcp-use generates its own per-widget Tailwind entry under .mcp-use/ and offers no hook to add one: resources/styles.css has never been part of the widget build. Noted in the file. Status colours (red/amber/green mastery bars, at-risk and published pills) are deliberately left unthemed — they carry meaning, not brand. Findings from the review pass this enabled are tracked in #571. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017sLtN12VfnLziCpst3Pfo2 --- mcp-server/.env.example | 5 + mcp-server/.gitignore | 3 + mcp-server/docs/WIDGET_DEMO_DATA.md | 90 + mcp-server/index.ts | 30 +- .../resources/artifact-sandbox/widget.tsx | 9 +- .../resources/course-catalog/widget.tsx | 9 +- .../resources/course-dashboard/widget.tsx | 9 +- mcp-server/resources/course-detail/widget.tsx | 13 +- .../resources/exam-readiness/widget.tsx | 9 +- .../resources/exam-submissions/widget.tsx | 11 +- mcp-server/resources/flashcards/widget.tsx | 17 +- .../resources/gamification-profile/widget.tsx | 12 +- .../resources/landing-page-preview/widget.tsx | 3 + .../resources/lesson-preview/widget.tsx | 7 +- mcp-server/resources/lesson-viewer/widget.tsx | 11 +- .../resources/my-exam-results/widget.tsx | 9 +- mcp-server/resources/my-learning/widget.tsx | 9 +- .../resources/practice-player/widget.tsx | 17 +- .../resources/school-overview/widget.tsx | 9 +- mcp-server/resources/shared/branding.tsx | 127 ++ .../resources/shared/lesson/components.tsx | 32 +- .../resources/shared/lesson/renderer.tsx | 2 +- .../student-progress-roster/widget.tsx | 11 +- mcp-server/resources/study-plan/widget.tsx | 11 +- mcp-server/resources/styles.css | 14 + .../resources/submission-grader/widget.tsx | 11 +- mcp-server/scripts/shoot-demo-widgets.sh | 77 + mcp-server/src/branding.test.ts | 71 + mcp-server/src/branding.ts | 79 + mcp-server/src/demo-data.ts | 1905 +++++++++++++++++ mcp-server/src/env.ts | 14 + mcp-server/src/register.ts | 31 +- mcp-server/src/tool-policy.ts | 5 + mcp-server/src/tools/demo.ts | 112 + 34 files changed, 2697 insertions(+), 87 deletions(-) create mode 100644 mcp-server/docs/WIDGET_DEMO_DATA.md create mode 100644 mcp-server/resources/shared/branding.tsx create mode 100755 mcp-server/scripts/shoot-demo-widgets.sh create mode 100644 mcp-server/src/branding.test.ts create mode 100644 mcp-server/src/branding.ts create mode 100644 mcp-server/src/demo-data.ts create mode 100644 mcp-server/src/tools/demo.ts diff --git a/mcp-server/.env.example b/mcp-server/.env.example index f4d5aa18..0ce59b48 100644 --- a/mcp-server/.env.example +++ b/mcp-server/.env.example @@ -38,3 +38,8 @@ MCP_SERVER_URL=http://localhost:3000 # Bind address — mcp-use defaults to "localhost", which is loopback-only. # In Docker/production set 0.0.0.0 (the Dockerfile already does). # HOST=0.0.0.0 + +# Dev only — register the lms_demo_* widget preview tools and drop OAuth so the +# inspector can render every widget from src/demo-data.ts with no DB and no +# login. Ignored when NODE_ENV=production. See docs/WIDGET_DEMO_DATA.md. +MCP_DEMO_WIDGETS=0 diff --git a/mcp-server/.gitignore b/mcp-server/.gitignore index 0a46fa83..19523040 100644 --- a/mcp-server/.gitignore +++ b/mcp-server/.gitignore @@ -42,3 +42,6 @@ Thumbs.db *.swo *~ + +# Widget preview renders (regenerate with scripts/shoot-demo-widgets.sh) +demo-shots/ diff --git a/mcp-server/docs/WIDGET_DEMO_DATA.md b/mcp-server/docs/WIDGET_DEMO_DATA.md new file mode 100644 index 00000000..cf4e66e0 --- /dev/null +++ b/mcp-server/docs/WIDGET_DEMO_DATA.md @@ -0,0 +1,90 @@ +# Widget demo data & preview mode + +Every MCP App widget in `resources/` has hand-written fixtures so you can look at +it — including its empty and edge states — with **no database, no seed data and +no OAuth login**. + +## Run it + +```bash +cd mcp-server +MCP_DEMO_WIDGETS=1 npm run dev +# → Inspector: http://localhost:3000/inspector?autoConnect=http%3A%2F%2Flocalhost%3A3000%2Fmcp +``` + +Pick any `lms_demo_*` tool, optionally set `variant`, hit **Execute**. The widget +renders in the response pane (the ⤢ button gives it the full pane). + +Headless PNGs of every fixture: + +```bash +./scripts/shoot-demo-widgets.sh dark # → demo-shots/dark/--.png +./scripts/shoot-demo-widgets.sh light +``` + +## How the mode works + +| Piece | File | +| --- | --- | +| The fixtures | `src/demo-data.ts` (`WIDGET_DEMOS`) | +| One `lms_demo_` tool per entry | `src/tools/demo.ts` | +| The gate: `MCP_DEMO_WIDGETS=1` **and** `NODE_ENV !== production` | `src/env.ts` → `demoWidgetsEnabled()` | + +Three deliberate consequences of the flag, all dev-only: + +1. **OAuth is not installed** (`index.ts`). Bearer auth on `/mcp` would 401 the + inspector before any handler ran, and the whole point is rendering widgets + without Supabase. +2. **Demo tools register before `installToolGuards()`**, so the role gate does + not reject them (an inspector session with no login has no tenant role) and + they never write to `mcp_audit_log`. +3. **`tools/list` shows only the 18 demo tools.** With no auth there is no role, + so the existing policy filter hides everything else. Real tools still refuse + to run — `LmsSession.fromContext` throws "Authentication required." + +Widget→host calls (`useCallTool`) still fail in this mode: `lms_grade_review`, +`lms_complete_lesson` etc. are not reachable without a session. That is expected +— you are looking at rendering, not round-trips. + +## School branding + +Widgets pick up the tenant's `primary_color` the way the app does, so a school's +widgets match its dashboard. + +- **Server** — `src/branding.ts` reads `tenants.name / logo_url / primary_color / + secondary_color` on the caller's RLS-scoped client, cached 5 min per tenant. + `installToolGuards` (`src/register.ts`) attaches it to the `_meta` of any + result that has `structuredContent`, so every widget-rendering tool is themed + without touching its handler. +- **Widget** — `resources/shared/branding.tsx` reads it from + `useWidget().metadata` and emits a `:root` ramp (`--brand-50 … --brand-950`) + derived from the one colour with `color-mix()`. Every widget renders `` + in both its pending and loaded branches. +- **Styling** — accents are `bg-[var(--brand-600)]`, `text-[var(--brand-400)]`, … + With no tenant colour the component emits Tailwind's violet ramp, so an + unbranded school renders exactly as before. + +Two constraints worth knowing before you touch this: + +- **`resources/styles.css` is not part of the build.** `mcp-use dev/build` + generates its own Tailwind entry per widget under `.mcp-use//styles.css` + and imports that; there is no CLI hook to add your own. `@theme`, `@utility` + and plain rules put in `resources/styles.css` silently do nothing — which is + why the ramp is CSS custom properties + arbitrary-value utilities rather than + a `brand-600` theme colour. +- **Status colours stay semantic.** Green/amber/red mastery bars, `at risk` and + `published` pills are meaning, not brand — they are deliberately not themed. + +Preview any widget under a fake school with the `brand` argument on the demo +tools: `none` (default), `ocean`, `sunset`, `forest`. + +## Editing fixtures + +Fixtures must satisfy each widget's own `propsSchema`. When you change a widget +schema, update the fixture in the same commit: `src/demo-data.ts` is the only +committed example of each widget's payload, and the demo tool will throw on a +mismatch the moment you execute it. + +Keep the awkward values. Null descriptions, comma-string `tags`, `0%`, ungraded +rows and unnamed students are in there on purpose — they are what the widgets +actually get, and they are what surfaces layout bugs. diff --git a/mcp-server/index.ts b/mcp-server/index.ts index cfb1db86..b0f01f69 100644 --- a/mcp-server/index.ts +++ b/mcp-server/index.ts @@ -1,5 +1,5 @@ import { MCPServer, oauthSupabaseProvider } from "mcp-use/server"; -import { getSupabaseUrl, shouldVerifyJwt } from "./src/env.js"; +import { getSupabaseUrl, shouldVerifyJwt, demoWidgetsEnabled } from "./src/env.js"; import { installToolGuards } from "./src/register.js"; import { installToolPolicy } from "./src/tool-policy.js"; import { installAuthRoutes } from "./src/auth-routes.js"; @@ -18,6 +18,7 @@ import { registerFlashcardTools } from "./src/tools/flashcards.js"; import { registerStudyPlanTools } from "./src/tools/study-plan.js"; import { registerAskTeacherTools } from "./src/tools/ask-teacher.js"; import { registerLandingPageTools } from "./src/tools/landing-pages.js"; +import { registerDemoTools } from "./src/tools/demo.js"; import { registerResources } from "./src/resources.js"; import { registerPrompts } from "./src/prompts.js"; @@ -49,12 +50,31 @@ const server = new MCPServer({ ], // Supabase OAuth 2.1: clients authenticate against Supabase; we verify the // resulting JWTs. Tenant/role claims come from the LMS custom_access_token_hook. - oauth: oauthSupabaseProvider({ - supabaseUrl: getSupabaseUrl(), - verifyJwt: shouldVerifyJwt(), - }), + // + // Widget-preview mode (MCP_DEMO_WIDGETS=1, dev only) drops OAuth entirely: + // bearer auth on /mcp would otherwise 401 the inspector before any handler + // runs, and the whole point of the mode is rendering widgets with no Supabase + // and no login. With no auth there is no tenant role, so `tools/list` exposes + // only the `lms_demo_*` fixtures — every real tool still refuses to run + // ("Authentication required" from LmsSession) because it has no session. + ...(demoWidgetsEnabled() + ? {} + : { + oauth: oauthSupabaseProvider({ + supabaseUrl: getSupabaseUrl(), + verifyJwt: shouldVerifyJwt(), + }), + }), }); +// Dev-only widget previews (MCP_DEMO_WIDGETS=1, never in production). Must be +// registered BEFORE installToolGuards: an inspector session with no LMS login +// has no tenant role, and the guards would reject every call. These handlers +// serve static fixtures and never touch a session or Supabase. +if (demoWidgetsEnabled()) { + registerDemoTools(server); +} + // Per-tool guards: role-based call gating + audit logging to mcp_audit_log. // Must run BEFORE registering tools — it monkey-patches server.tool to wrap // every handler (the tool name isn't available in mcp-use 1.32.0 middleware). diff --git a/mcp-server/resources/artifact-sandbox/widget.tsx b/mcp-server/resources/artifact-sandbox/widget.tsx index cf3dec7e..24204b28 100644 --- a/mcp-server/resources/artifact-sandbox/widget.tsx +++ b/mcp-server/resources/artifact-sandbox/widget.tsx @@ -6,6 +6,7 @@ import { useCallTool, type WidgetMetadata, } from "mcp-use/react"; +import { Brand } from "../shared/branding"; import { z } from "zod"; // ── Schema ────────────────────────────────────────────────────────────────── @@ -97,9 +98,10 @@ export default function ArtifactSandbox() { if (isPending) { return ( +
-
+

Rendering artifact…

@@ -120,7 +122,7 @@ export default function ArtifactSandbox() { onClick={() => setTab(id)} className={`cursor-pointer rounded-lg border-none px-3.5 py-1.5 text-[13px] transition-all duration-150 ${ tab === id - ? "bg-violet-50 font-semibold text-violet-600 dark:bg-violet-950 dark:text-violet-400" + ? "bg-[var(--brand-50)] font-semibold text-[var(--brand-600)] dark:bg-[var(--brand-950)] dark:text-[var(--brand-400)]" : "bg-transparent font-medium text-zinc-500 dark:text-zinc-400" }`} > @@ -130,6 +132,7 @@ export default function ArtifactSandbox() { return ( +
{/* Header */} @@ -201,7 +204,7 @@ export default function ArtifactSandbox() { disabled={saving || !dirty} className={`rounded-lg border-none px-[18px] py-2 text-[13px] font-semibold transition-all duration-150 ${ dirty - ? "cursor-pointer bg-violet-600 text-white dark:bg-violet-400 dark:text-zinc-950" + ? "cursor-pointer bg-[var(--brand-600)] text-white dark:bg-[var(--brand-400)] dark:text-zinc-950" : "cursor-not-allowed bg-zinc-200 text-zinc-400 dark:bg-zinc-800 dark:text-zinc-500" } ${saving ? "cursor-not-allowed opacity-70" : ""}`} > diff --git a/mcp-server/resources/course-catalog/widget.tsx b/mcp-server/resources/course-catalog/widget.tsx index 6a5b1519..ef71b3b3 100644 --- a/mcp-server/resources/course-catalog/widget.tsx +++ b/mcp-server/resources/course-catalog/widget.tsx @@ -6,6 +6,7 @@ import { useCallTool, type WidgetMetadata, } from "mcp-use/react"; +import { Brand } from "../shared/branding"; import { z } from "zod"; // ── Schema ────────────────────────────────────────────────────────────────── @@ -75,9 +76,10 @@ export default function CourseCatalog() { if (isPending) { return ( +
-
+

Browsing catalog…

@@ -114,6 +116,7 @@ export default function CourseCatalog() { return ( +
@@ -201,12 +204,12 @@ export default function CourseCatalog() { ) : course.has_access ? ( - + Access ) : ( diff --git a/mcp-server/resources/course-dashboard/widget.tsx b/mcp-server/resources/course-dashboard/widget.tsx index 77118eb0..912616de 100644 --- a/mcp-server/resources/course-dashboard/widget.tsx +++ b/mcp-server/resources/course-dashboard/widget.tsx @@ -5,6 +5,7 @@ import { useWidgetTheme, type WidgetMetadata, } from "mcp-use/react"; +import { Brand } from "../shared/branding"; import { z } from "zod"; // ── Schema ────────────────────────────────────────────────────────────────── @@ -60,7 +61,7 @@ function statusColor(status: string): string { case "draft": return "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400"; case "archived": - return "bg-violet-50 text-violet-600 dark:bg-violet-950 dark:text-violet-400"; + return "bg-[var(--brand-50)] text-[var(--brand-600)] dark:bg-[var(--brand-950)] dark:text-[var(--brand-400)]"; default: return "bg-amber-100 text-amber-600 dark:bg-amber-950 dark:text-amber-400"; } @@ -78,9 +79,10 @@ export default function CourseDashboard() { if (isPending) { return ( +
-
+

Loading courses…

@@ -99,6 +101,7 @@ export default function CourseDashboard() { return ( +
{/* Header */} @@ -122,7 +125,7 @@ export default function CourseDashboard() { onClick={() => setActiveFilter(s)} className={`cursor-pointer rounded-full border px-3 py-[5px] text-xs transition-all duration-150 ${ active - ? "border-violet-600 bg-violet-50 font-semibold text-violet-600 dark:border-violet-400 dark:bg-violet-950 dark:text-violet-400" + ? "border-[var(--brand-600)] bg-[var(--brand-50)] font-semibold text-[var(--brand-600)] dark:border-[var(--brand-400)] dark:bg-[var(--brand-950)] dark:text-[var(--brand-400)]" : "border-zinc-200 bg-transparent font-normal text-zinc-500 dark:border-zinc-800 dark:text-zinc-400" }`} > diff --git a/mcp-server/resources/course-detail/widget.tsx b/mcp-server/resources/course-detail/widget.tsx index a4d9f4e4..fd1c9129 100644 --- a/mcp-server/resources/course-detail/widget.tsx +++ b/mcp-server/resources/course-detail/widget.tsx @@ -6,6 +6,7 @@ import { useCallTool, type WidgetMetadata, } from "mcp-use/react"; +import { Brand } from "../shared/branding"; import { z } from "zod"; // ── Schema ────────────────────────────────────────────────────────────────── @@ -63,7 +64,7 @@ function statusPill(status: string): string { case "draft": return "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400"; case "archived": - return "bg-violet-50 text-violet-600 dark:bg-violet-950 dark:text-violet-400"; + return "bg-[var(--brand-50)] text-[var(--brand-600)] dark:bg-[var(--brand-950)] dark:text-[var(--brand-400)]"; default: return "bg-amber-100 text-amber-600 dark:bg-amber-950 dark:text-amber-400"; } @@ -115,9 +116,10 @@ export default function CourseDetail() { if (isPending) { return ( +
-
+

Loading course…

@@ -135,6 +137,7 @@ export default function CourseDetail() { return ( +
{/* Course header */} @@ -174,9 +177,9 @@ export default function CourseDetail() { @@ -214,7 +217,7 @@ export default function ExamReadiness() { `Generate a practice quiz on "${t.label}" with lms_practice_quiz — focus on my misses. (Course: ${course_title})` ) } - className="cursor-pointer rounded-md border border-violet-600 bg-transparent px-2.5 py-1 text-xs font-semibold text-violet-600 dark:border-violet-400 dark:text-violet-400" + className="cursor-pointer rounded-md border border-[var(--brand-600)] bg-transparent px-2.5 py-1 text-xs font-semibold text-[var(--brand-600)] dark:border-[var(--brand-400)] dark:text-[var(--brand-400)]" > Practice this diff --git a/mcp-server/resources/exam-submissions/widget.tsx b/mcp-server/resources/exam-submissions/widget.tsx index 3dc6ccf6..2fc88f0e 100644 --- a/mcp-server/resources/exam-submissions/widget.tsx +++ b/mcp-server/resources/exam-submissions/widget.tsx @@ -6,6 +6,7 @@ import { useCallTool, type WidgetMetadata, } from "mcp-use/react"; +import { Brand } from "../shared/branding"; import { z } from "zod"; import { Markdown } from "../shared/markdown"; @@ -104,9 +105,10 @@ export default function ExamSubmissions() { if (isPending) { return ( +
-
+

Loading submissions…

@@ -140,6 +142,7 @@ export default function ExamSubmissions() { return ( +
{/* Header */} @@ -199,7 +202,7 @@ export default function ExamSubmissions() { isLast ? "" : "border-b border-zinc-200 dark:border-zinc-800" } ${ isExpanded - ? "bg-violet-50 dark:bg-violet-950" + ? "bg-[var(--brand-50)] dark:bg-[var(--brand-950)]" : "bg-transparent hover:bg-zinc-50 dark:hover:bg-zinc-800" }`} > @@ -224,7 +227,7 @@ export default function ExamSubmissions() { {/* Expanded detail panel */} {isExpanded && (
Score - + {detail.score}%
diff --git a/mcp-server/resources/flashcards/widget.tsx b/mcp-server/resources/flashcards/widget.tsx index 22cdd08b..a1a4c60c 100644 --- a/mcp-server/resources/flashcards/widget.tsx +++ b/mcp-server/resources/flashcards/widget.tsx @@ -6,6 +6,7 @@ import { useCallTool, type WidgetMetadata, } from "mcp-use/react"; +import { Brand } from "../shared/branding"; import { z } from "zod"; import { Markdown } from "../shared/markdown"; @@ -51,7 +52,7 @@ const ratingColor: Record = { again: "border-red-600 text-red-600 dark:border-red-400 dark:text-red-400", hard: "border-amber-600 text-amber-600 dark:border-amber-400 dark:text-amber-400", good: "border-green-600 text-green-600 dark:border-green-400 dark:text-green-400", - easy: "border-violet-600 text-violet-600 dark:border-violet-400 dark:text-violet-400", + easy: "border-[var(--brand-600)] text-[var(--brand-600)] dark:border-[var(--brand-400)] dark:text-[var(--brand-400)]", }; export default function Flashcards() { @@ -74,9 +75,10 @@ export default function Flashcards() { if (isPending) { return ( +
-
+
@@ -107,6 +109,7 @@ export default function Flashcards() { if (cards.length === 0) { return ( +
@@ -135,6 +138,7 @@ export default function Flashcards() { : ". All comfortable — what should I tackle next?"); return ( +
@@ -167,7 +171,7 @@ export default function Flashcards() { )} @@ -180,6 +184,7 @@ export default function Flashcards() { return ( +
@@ -196,7 +201,7 @@ export default function Flashcards() {
@@ -213,14 +218,14 @@ export default function Flashcards() { }} className={`flex min-h-[120px] cursor-pointer flex-col items-center justify-center rounded-xl border bg-white px-7 py-9 text-center select-none dark:bg-zinc-900 ${ flipped - ? "border-violet-600 dark:border-violet-400" + ? "border-[var(--brand-600)] dark:border-[var(--brand-400)]" : "border-zinc-200 dark:border-zinc-800" }`} > diff --git a/mcp-server/resources/gamification-profile/widget.tsx b/mcp-server/resources/gamification-profile/widget.tsx index 19708eba..7a538cef 100644 --- a/mcp-server/resources/gamification-profile/widget.tsx +++ b/mcp-server/resources/gamification-profile/widget.tsx @@ -4,6 +4,7 @@ import { useWidgetTheme, type WidgetMetadata, } from "mcp-use/react"; +import { Brand } from "../shared/branding"; import { z } from "zod"; // ── Schema ────────────────────────────────────────────────────────────────── @@ -62,7 +63,7 @@ function tierClass(tier: string | null): string { case "diamond": return "bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-300"; default: - return "bg-violet-50 text-violet-600 dark:bg-violet-950 dark:text-violet-400"; + return "bg-[var(--brand-50)] text-[var(--brand-600)] dark:bg-[var(--brand-950)] dark:text-[var(--brand-400)]"; } } @@ -82,9 +83,10 @@ export default function GamificationProfile() { if (isPending) { return ( +
-
+

Loading your progress…

@@ -95,6 +97,7 @@ export default function GamificationProfile() { if (!props.has_profile) { return ( +
@@ -134,12 +137,13 @@ export default function GamificationProfile() { return ( +
{/* Level hero */}
-
+
{props.level_icon ?? "⭐"}
@@ -156,7 +160,7 @@ export default function GamificationProfile() { {/* XP progress to next level */}
diff --git a/mcp-server/resources/landing-page-preview/widget.tsx b/mcp-server/resources/landing-page-preview/widget.tsx index 60550fdd..314c1c6a 100644 --- a/mcp-server/resources/landing-page-preview/widget.tsx +++ b/mcp-server/resources/landing-page-preview/widget.tsx @@ -4,6 +4,7 @@ import { useWidgetTheme, type WidgetMetadata, } from "mcp-use/react"; +import { Brand } from "../shared/branding"; import { z } from "zod"; // ── Schema ────────────────────────────────────────────────────────────────── @@ -308,6 +309,7 @@ export default function LandingPagePreview() { if (isPending) { return ( +
@@ -333,6 +335,7 @@ export default function LandingPagePreview() { return ( +
{/* Page header */} diff --git a/mcp-server/resources/lesson-preview/widget.tsx b/mcp-server/resources/lesson-preview/widget.tsx index d452482f..56274486 100644 --- a/mcp-server/resources/lesson-preview/widget.tsx +++ b/mcp-server/resources/lesson-preview/widget.tsx @@ -4,6 +4,7 @@ import { useWidgetTheme, type WidgetMetadata, } from "mcp-use/react"; +import { Brand } from "../shared/branding"; import { z } from "zod"; import { LessonBody } from "../shared/lesson"; @@ -128,9 +129,10 @@ export default function LessonPreview() { if (isPending) { return ( +
-
+

Loading lesson…

@@ -142,13 +144,14 @@ export default function LessonPreview() { return ( +
{/* Lesson header */}
{/* Breadcrumb-ish sequence + status */}
- + Lesson {lesson.sequence} +
-
+

Opening lesson…

@@ -87,12 +89,13 @@ export default function LessonViewer() { return ( +
{/* Header */}
- + Lesson {lesson.sequence} @@ -163,7 +166,7 @@ export default function LessonViewer() { className={`cursor-pointer rounded-[10px] border-[1.5px] bg-transparent px-[18px] py-[9px] text-[13.5px] font-semibold disabled:cursor-default ${ askedTutor ? "border-green-600 text-green-600 dark:border-green-400 dark:text-green-400" - : "border-violet-600 text-violet-600 dark:border-violet-400 dark:text-violet-400" + : "border-[var(--brand-600)] text-[var(--brand-600)] dark:border-[var(--brand-400)] dark:text-[var(--brand-400)]" }`} > {askedTutor ? "Asked the tutor ✓" : "I don't understand this 🙋"} @@ -176,7 +179,7 @@ export default function LessonViewer() { diff --git a/mcp-server/resources/my-exam-results/widget.tsx b/mcp-server/resources/my-exam-results/widget.tsx index 286f572f..84e25c6c 100644 --- a/mcp-server/resources/my-exam-results/widget.tsx +++ b/mcp-server/resources/my-exam-results/widget.tsx @@ -5,6 +5,7 @@ import { useWidgetTheme, type WidgetMetadata, } from "mcp-use/react"; +import { Brand } from "../shared/branding"; import { z } from "zod"; import { Markdown } from "../shared/markdown"; @@ -66,9 +67,10 @@ export default function MyExamResults() { if (isPending) { return ( +
-
+

Loading your exam results…

@@ -89,6 +91,7 @@ export default function MyExamResults() { return ( +
@@ -168,14 +171,14 @@ export default function MyExamResults() {
{r.feedback && ( - + {isOpen ? "Hide feedback ▲" : "Feedback ▼"} )}
{isOpen && r.feedback && ( -
+
)} diff --git a/mcp-server/resources/my-learning/widget.tsx b/mcp-server/resources/my-learning/widget.tsx index 5904d5ad..c131cb6a 100644 --- a/mcp-server/resources/my-learning/widget.tsx +++ b/mcp-server/resources/my-learning/widget.tsx @@ -4,6 +4,7 @@ import { useWidgetTheme, type WidgetMetadata, } from "mcp-use/react"; +import { Brand } from "../shared/branding"; import { z } from "zod"; // ── Schema ────────────────────────────────────────────────────────────────── @@ -55,9 +56,10 @@ export default function MyLearning() { if (isPending) { return ( +
-
+

Loading your courses…

@@ -69,6 +71,7 @@ export default function MyLearning() { return ( +
{/* Header */} @@ -116,7 +119,7 @@ export default function MyLearning() { className={`shrink-0 rounded-full px-2.5 py-0.5 text-xs font-bold tabular-nums ${ done ? "bg-green-100 text-green-600 dark:bg-green-900 dark:text-green-400" - : "bg-violet-50 text-violet-600 dark:bg-violet-950 dark:text-violet-400" + : "bg-[var(--brand-50)] text-[var(--brand-600)] dark:bg-[var(--brand-950)] dark:text-[var(--brand-400)]" }`} > {course.progress}% @@ -129,7 +132,7 @@ export default function MyLearning() { className={`h-full rounded-full transition-[width] duration-400 ease-out ${ done ? "bg-green-600 dark:bg-green-400" - : "bg-violet-600 dark:bg-violet-400" + : "bg-[var(--brand-600)] dark:bg-[var(--brand-400)]" }`} style={{ width: `${Math.min(course.progress, 100)}%` }} /> diff --git a/mcp-server/resources/practice-player/widget.tsx b/mcp-server/resources/practice-player/widget.tsx index 8e4f6cf2..5255501f 100644 --- a/mcp-server/resources/practice-player/widget.tsx +++ b/mcp-server/resources/practice-player/widget.tsx @@ -6,6 +6,7 @@ import { useCallTool, type WidgetMetadata, } from "mcp-use/react"; +import { Brand } from "../shared/branding"; import { z } from "zod"; // ── Schema ────────────────────────────────────────────────────────────────── @@ -171,12 +172,12 @@ const containerClass = const choiceClass = (selected: boolean, layout = "block w-full text-left") => `mb-2 cursor-pointer rounded-[10px] border-[1.5px] px-3.5 py-2.5 text-sm text-zinc-900 dark:text-zinc-100 ${layout} ${ selected - ? "border-violet-600 bg-violet-50 font-semibold dark:border-violet-400 dark:bg-violet-950" + ? "border-[var(--brand-600)] bg-[var(--brand-50)] font-semibold dark:border-[var(--brand-400)] dark:bg-[var(--brand-950)]" : "border-zinc-200 bg-white font-normal dark:border-zinc-800 dark:bg-zinc-900" }`; const primaryBtnClass = - "cursor-pointer rounded-[10px] border-none bg-violet-600 px-4.5 py-[9px] text-[13.5px] font-semibold text-white dark:bg-violet-400"; + "cursor-pointer rounded-[10px] border-none bg-[var(--brand-600)] px-4.5 py-[9px] text-[13.5px] font-semibold text-white dark:bg-[var(--brand-400)]"; const inputClass = "box-border w-full rounded-[10px] border-[1.5px] border-zinc-200 bg-white px-3 py-2.5 text-sm text-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100"; @@ -221,6 +222,7 @@ export default function PracticePlayer() { if (isPending) { return ( +
Setting up practice… @@ -235,6 +237,7 @@ export default function PracticePlayer() { if (questions.length === 0) { return ( +
No practice questions could be generated for “{topic}”. Ask the @@ -353,6 +356,7 @@ export default function PracticePlayer() { return ( +

@@ -403,11 +407,12 @@ export default function PracticePlayer() { // ── Question screen ──────────────────────────────────────────────────────── return ( +
{/* Header + progress dots */}
- + {isMixed ? `${MIXED_COPY.pill} · ${q.topic ?? topic}` : `Practice · ${topic}`} @@ -421,7 +426,7 @@ export default function PracticePlayer() { key={dot.id} className={`size-2 rounded-full ${ i === index - ? "bg-violet-600 dark:bg-violet-400" + ? "bg-[var(--brand-600)] dark:bg-[var(--brand-400)]" : answers[dot.id] !== undefined ? "bg-green-600 dark:bg-green-400" : "bg-zinc-200 dark:bg-zinc-800" @@ -432,7 +437,7 @@ export default function PracticePlayer() {
{isMixed && index === 0 && ( -

+

{MIXED_COPY.banner}

)} @@ -494,7 +499,7 @@ export default function PracticePlayer() { const matched = ((answer as Record) ?? {})[p.left]; const border = pendingLeft === p.left - ? "border-violet-600 bg-violet-50 font-semibold dark:border-violet-400 dark:bg-violet-950" + ? "border-[var(--brand-600)] bg-[var(--brand-50)] font-semibold dark:border-[var(--brand-400)] dark:bg-[var(--brand-950)]" : matched ? "border-green-600 bg-white font-normal dark:border-green-400 dark:bg-zinc-900" : "border-zinc-200 bg-white font-normal dark:border-zinc-800 dark:bg-zinc-900"; diff --git a/mcp-server/resources/school-overview/widget.tsx b/mcp-server/resources/school-overview/widget.tsx index ef54c48d..b24238ee 100644 --- a/mcp-server/resources/school-overview/widget.tsx +++ b/mcp-server/resources/school-overview/widget.tsx @@ -4,6 +4,7 @@ import { useWidgetTheme, type WidgetMetadata, } from "mcp-use/react"; +import { Brand } from "../shared/branding"; import { z } from "zod"; // ── Schema ────────────────────────────────────────────────────────────────── @@ -68,7 +69,7 @@ function statusPill(status: string): string { function completionColor(pct: number): string { if (pct >= 80) return "bg-green-600 dark:bg-green-400"; - if (pct >= 40) return "bg-violet-600 dark:bg-violet-400"; + if (pct >= 40) return "bg-[var(--brand-600)] dark:bg-[var(--brand-400)]"; if (pct > 0) return "bg-amber-600 dark:bg-amber-400"; return "bg-zinc-300 dark:bg-zinc-600"; } @@ -83,9 +84,10 @@ export default function SchoolOverview() { if (isPending) { return ( +
-
+

Crunching school stats…

@@ -122,6 +124,7 @@ export default function SchoolOverview() { return ( +
{/* Header */} @@ -151,7 +154,7 @@ export default function SchoolOverview() { "Avg completion", `${school.completion_rate}%`, undefined, - "text-violet-600 dark:text-violet-400" + "text-[var(--brand-600)] dark:text-[var(--brand-400)]" )} {kpi( "Avg exam score", diff --git a/mcp-server/resources/shared/branding.tsx b/mcp-server/resources/shared/branding.tsx new file mode 100644 index 00000000..f5613fb9 --- /dev/null +++ b/mcp-server/resources/shared/branding.tsx @@ -0,0 +1,127 @@ +import { useWidget } from "mcp-use/react"; + +/** + * Tenant branding for widgets — the widget-side half of school theming. + * + * The web app injects the school's colours as CSS custom properties in the page + * head (`components/tenant/tenant-css-vars-server.tsx`). Widgets render in a + * host iframe with no access to that, so the server sends the same values in + * the tool result's `_meta` (see `src/branding.ts`) and `` writes + * them into the widget document. + * + * The ramp is derived from ONE colour (`tenants.primary_color`) with + * `color-mix()` in oklab, which is close enough to Tailwind's own ramp to be + * indistinguishable in the sizes widgets use it at, and needs no colour maths + * in JS. When the tenant has no primary colour we emit nothing at all and the + * `@theme inline` fallbacks in `styles.css` keep the platform violet. + */ +export interface Branding { + name?: string | null; + logo_url?: string | null; + primary_color?: string | null; + secondary_color?: string | null; +} + +/** `_meta` key the server namespaces branding under. Keep in sync with src/branding.ts. */ +const BRANDING_META_KEY = "lms/branding"; + +/** + * Read the tenant branding the server attached to this tool result. + * Returns `null` when the host sent none (unbranded tenant, or a widget + * rendered outside a branded session). + */ +export function useBranding(): Branding | null { + const { metadata } = useWidget>(); + const meta = metadata as Record | null | undefined; + const branding = meta?.[BRANDING_META_KEY] as Branding | undefined; + if (!branding || typeof branding !== "object") return null; + return branding; +} + +/** + * A CSS colour we are willing to interpolate and paste into a stylesheet. + * + * `primary_color` is tenant-controlled text that ends up inside a `; +} + +/** + * Convenience wrapper: read branding from the tool result and apply it. + * Widgets that do not need the values themselves can just render ``. + */ +export function Brand() { + return ; +} diff --git a/mcp-server/resources/shared/lesson/components.tsx b/mcp-server/resources/shared/lesson/components.tsx index 47b265ec..9ba48bba 100644 --- a/mcp-server/resources/shared/lesson/components.tsx +++ b/mcp-server/resources/shared/lesson/components.tsx @@ -63,7 +63,7 @@ const cn = (...parts: (string | false | null | undefined)[]) => const CARD = "my-5 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"; const PRIMARY_BUTTON = - "cursor-pointer rounded-lg border-none bg-violet-600 px-4 py-2 text-[13px] font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50 dark:bg-violet-500"; + "cursor-pointer rounded-lg border-none bg-[var(--brand-600)] px-4 py-2 text-[13px] font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50 dark:bg-[var(--brand-500)]"; const GHOST_BUTTON = "flex cursor-pointer items-center gap-1.5 rounded-lg border border-zinc-200 bg-transparent px-3 py-2 text-[13px] font-medium text-zinc-600 dark:border-zinc-700 dark:text-zinc-300"; @@ -266,7 +266,7 @@ export function Quiz({ className={cn( "flex w-full items-start gap-3 rounded-lg border border-zinc-200 bg-transparent p-3 text-left text-[13px] text-zinc-700 dark:border-zinc-700 dark:text-zinc-200", submitted ? "cursor-default" : "cursor-pointer", - isSelected && !submitted && "border-violet-500 bg-violet-50 dark:bg-violet-950/40", + isSelected && !submitted && "border-[var(--brand-500)] bg-[var(--brand-50)] dark:bg-[var(--brand-950)]/40", showResult && isCorrectOption && "border-green-500 bg-green-50 dark:bg-green-950", showResult && isSelected && @@ -278,7 +278,7 @@ export function Quiz({ className={cn( "mt-0.5 flex size-5 shrink-0 items-center justify-center border border-zinc-300 text-[11px] font-semibold dark:border-zinc-600", allowMultiple ? "rounded" : "rounded-full", - isSelected && !submitted && "border-violet-600 bg-violet-600 text-white", + isSelected && !submitted && "border-[var(--brand-600)] bg-[var(--brand-600)] text-white", showResult && isCorrectOption && "border-green-500 bg-green-500 text-white", showResult && isSelected && @@ -480,7 +480,7 @@ export function Vocabulary({ type="button" onClick={toggle} aria-label={playing ? "Pause audio" : "Play pronunciation"} - className="flex size-8 cursor-pointer items-center justify-center rounded-full border-none bg-violet-100 text-violet-700 dark:bg-violet-950 dark:text-violet-300" + className="flex size-8 cursor-pointer items-center justify-center rounded-full border-none bg-[var(--brand-100)] text-[var(--brand-700)] dark:bg-[var(--brand-950)] dark:text-[var(--brand-300)]" > {playing ? ( @@ -542,7 +542,7 @@ export function Step({ title, number, children }: Record) { return (
-
+
{assigned ?? "•"}
@@ -568,9 +568,9 @@ export function Definition({ children, }: Record) { return ( -
+
- +
@@ -849,7 +849,7 @@ export function Audio({ src, title }: Record) {
{title && (
- +

{str(title)}

@@ -890,7 +890,7 @@ export function Embed({ url, title, caption }: Record) { export function FileDownload({ url, filename, description }: Record) { return (
- +

{str(filename)} @@ -906,7 +906,7 @@ export function FileDownload({ url, filename, description }: Record download={str(filename)} target="_blank" rel="noopener noreferrer" - className="shrink-0 rounded-lg bg-violet-600 px-4 py-2 text-[13px] font-semibold text-white no-underline dark:bg-violet-500" + className="shrink-0 rounded-lg bg-[var(--brand-600)] px-4 py-2 text-[13px] font-semibold text-white no-underline dark:bg-[var(--brand-500)]" > Download @@ -937,7 +937,7 @@ export function FlashcardSet({ cards }: Record) { className={cn( "mx-auto flex h-48 w-full max-w-md cursor-pointer items-center justify-center rounded-xl border border-zinc-200 p-6 text-center text-[13px] font-medium shadow-sm dark:border-zinc-800", flipped - ? "bg-violet-50 text-zinc-900 dark:bg-violet-950/40 dark:text-zinc-100" + ? "bg-[var(--brand-50)] text-zinc-900 dark:bg-[var(--brand-950)]/40 dark:text-zinc-100" : "bg-white text-zinc-900 dark:bg-zinc-900 dark:text-zinc-100" )} > @@ -1164,7 +1164,7 @@ export function MatchingPairs({ pairs, explanation }: Record) { "w-full rounded-lg border border-zinc-200 p-3 text-left text-[13px] text-zinc-700 dark:border-zinc-700 dark:text-zinc-200", checked ? "cursor-default" : "cursor-pointer", colorForTerm(termIndex) ?? "bg-transparent", - selectedTerm === termIndex && "ring-2 ring-violet-500", + selectedTerm === termIndex && "ring-2 ring-[var(--brand-500)]", checked && connections[termIndex] === termIndex && "border-green-500", checked && connections[termIndex] !== termIndex && "border-red-500" )} @@ -1186,7 +1186,7 @@ export function MatchingPairs({ pairs, explanation }: Record) { "w-full rounded-lg border border-zinc-200 p-3 text-left text-[13px] text-zinc-700 dark:border-zinc-700 dark:text-zinc-200", checked ? "cursor-default" : "cursor-pointer", colorForMatch(matchIndex) ?? "bg-transparent", - selectedMatch === matchIndex && "ring-2 ring-violet-500" + selectedMatch === matchIndex && "ring-2 ring-[var(--brand-500)]" )} > {str(entries[matchIndex]?.match)} @@ -1266,7 +1266,7 @@ export function Ordering({ items, explanation }: Record) { className={cn( "flex w-full items-center gap-3 rounded-lg border border-zinc-200 bg-transparent p-3 text-left text-[13px] text-zinc-700 dark:border-zinc-700 dark:text-zinc-200", checked ? "cursor-default" : "cursor-pointer", - selected === index && "bg-violet-50 ring-2 ring-violet-500 dark:bg-violet-950/40", + selected === index && "bg-[var(--brand-50)] ring-2 ring-[var(--brand-500)] dark:bg-[var(--brand-950)]/40", checked && correct && "border-green-500 bg-green-50 dark:bg-green-950", checked && !correct && "border-red-500 bg-red-50 dark:bg-red-950" )} @@ -1334,8 +1334,8 @@ export function LessonCheckpoint({ checkpointId, label }: Record) { if (id === undefined) return null; return ( -

-
+
+
diff --git a/mcp-server/resources/shared/lesson/renderer.tsx b/mcp-server/resources/shared/lesson/renderer.tsx index 626fd680..7823026e 100644 --- a/mcp-server/resources/shared/lesson/renderer.tsx +++ b/mcp-server/resources/shared/lesson/renderer.tsx @@ -188,7 +188,7 @@ function renderNode(node: MdNode, ctx: WalkContext): ReactNode { target="_blank" rel="noopener noreferrer" title={typeof node.title === "string" ? node.title : undefined} - className="font-medium text-violet-700 underline decoration-violet-400 underline-offset-2 dark:text-violet-400" + className="font-medium text-[var(--brand-700)] underline decoration-[var(--brand-400)] underline-offset-2 dark:text-[var(--brand-400)]" > {renderChildren(node, ctx)} diff --git a/mcp-server/resources/student-progress-roster/widget.tsx b/mcp-server/resources/student-progress-roster/widget.tsx index 23dcc746..d7625c4e 100644 --- a/mcp-server/resources/student-progress-roster/widget.tsx +++ b/mcp-server/resources/student-progress-roster/widget.tsx @@ -5,6 +5,7 @@ import { useWidgetTheme, type WidgetMetadata, } from "mcp-use/react"; +import { Brand } from "../shared/branding"; import { z } from "zod"; // ── Schema ────────────────────────────────────────────────────────────────── @@ -68,7 +69,7 @@ function relativeDate(s: string | null): string { function progressColor(pct: number | null): string { if (pct == null) return "bg-zinc-300 dark:bg-zinc-600"; if (pct >= 80) return "bg-green-600 dark:bg-green-400"; - if (pct >= 40) return "bg-violet-600 dark:bg-violet-400"; + if (pct >= 40) return "bg-[var(--brand-600)] dark:bg-[var(--brand-400)]"; if (pct > 0) return "bg-amber-600 dark:bg-amber-400"; return "bg-red-600 dark:bg-red-400"; } @@ -92,9 +93,10 @@ export default function StudentProgressRoster() { if (isPending) { return ( +
-
+

Loading roster…

@@ -122,6 +124,7 @@ export default function StudentProgressRoster() { return ( +
{/* Header */} @@ -141,7 +144,7 @@ export default function StudentProgressRoster() { {stat( "Avg progress", `${summary.avg_progress}%`, - "text-violet-600 dark:text-violet-400" + "text-[var(--brand-600)] dark:text-[var(--brand-400)]" )} {stat( "At risk", @@ -179,7 +182,7 @@ export default function StudentProgressRoster() { }`} > {/* Avatar */} -
+
{initials(s.student_name, s.student_id)}
diff --git a/mcp-server/resources/study-plan/widget.tsx b/mcp-server/resources/study-plan/widget.tsx index 35a0b05f..1927c138 100644 --- a/mcp-server/resources/study-plan/widget.tsx +++ b/mcp-server/resources/study-plan/widget.tsx @@ -6,6 +6,7 @@ import { useCallTool, type WidgetMetadata, } from "mcp-use/react"; +import { Brand } from "../shared/branding"; import { z } from "zod"; // Props produced by lms_get_study_plan (Epic #348 Phase 4, #359). @@ -73,9 +74,10 @@ export default function StudyPlan() { if (isPending) { return ( +
-
+
@@ -126,6 +128,7 @@ export default function StudyPlan() { return ( +
@@ -151,7 +154,7 @@ export default function StudyPlan() { className={ weekComplete ? "stroke-green-600 dark:stroke-green-400" - : "stroke-violet-600 dark:stroke-violet-400" + : "stroke-[var(--brand-600)] dark:stroke-[var(--brand-400)]" } /> Plan my week @@ -288,7 +291,7 @@ export default function StudyPlan() { "Let's plan next week: look at what I finished and missed this week, my next lessons, and due flashcards, then propose next week's goals and save them with lms_set_study_plan." ) } - className="cursor-pointer rounded-lg border border-violet-600 bg-transparent px-4 py-2 text-[13px] font-semibold text-violet-600 dark:border-violet-400 dark:text-violet-400" + className="cursor-pointer rounded-lg border border-[var(--brand-600)] bg-transparent px-4 py-2 text-[13px] font-semibold text-[var(--brand-600)] dark:border-[var(--brand-400)] dark:text-[var(--brand-400)]" > Plan next week with me diff --git a/mcp-server/resources/styles.css b/mcp-server/resources/styles.css index 1edfe1ec..061c461f 100644 --- a/mcp-server/resources/styles.css +++ b/mcp-server/resources/styles.css @@ -10,6 +10,20 @@ --color-scheme: light dark; } +/* + * ⚠️ THIS FILE IS NOT PART OF THE WIDGET BUILD. + * + * `mcp-use dev/build` generates its own Tailwind entry per widget at + * `.mcp-use//styles.css` (`@import "tailwindcss"` + `@source` lines) + * and imports THAT from the generated entry.tsx. Nothing imports this file, and + * the CLI exposes no hook to add one — so `@theme`, `@utility` or plain rules + * added here silently do nothing. + * + * That is why tenant theming (resources/shared/branding.tsx) uses CSS custom + * properties with arbitrary-value utilities — `bg-[var(--brand-600)]` — which + * Tailwind compiles straight from the scanned JSX and which need no theme entry. + */ + @variant dark (&:where(.dark, .dark *)); /* Custom styles */ diff --git a/mcp-server/resources/submission-grader/widget.tsx b/mcp-server/resources/submission-grader/widget.tsx index 62f6f892..e2274711 100644 --- a/mcp-server/resources/submission-grader/widget.tsx +++ b/mcp-server/resources/submission-grader/widget.tsx @@ -6,6 +6,7 @@ import { useCallTool, type WidgetMetadata, } from "mcp-use/react"; +import { Brand } from "../shared/branding"; import { z } from "zod"; import { Markdown } from "../shared/markdown"; @@ -75,7 +76,7 @@ function statusPill(status: string): { classes: string; label: string } { }, ai_reviewed: { classes: - "bg-violet-50 text-violet-600 dark:bg-violet-950 dark:text-violet-400", + "bg-[var(--brand-50)] text-[var(--brand-600)] dark:bg-[var(--brand-950)] dark:text-[var(--brand-400)]", label: "AI reviewed", }, pending_teacher_review: { @@ -128,9 +129,10 @@ export default function SubmissionGrader() { if (isPending) { return ( +
-
+

Loading submission…

@@ -166,6 +168,7 @@ export default function SubmissionGrader() { return ( +
{/* Header */} @@ -232,7 +235,7 @@ export default function SubmissionGrader() { className={`rounded-lg border-none px-5 py-2 text-[13px] font-semibold transition-all ${ saveDisabled ? "bg-zinc-200 text-zinc-400 dark:bg-zinc-800 dark:text-zinc-500" - : "bg-violet-600 text-white dark:bg-violet-400" + : "bg-[var(--brand-600)] text-white dark:bg-[var(--brand-400)]" } ${saving ? "cursor-not-allowed opacity-70" : "cursor-pointer"}`} > {saving @@ -274,7 +277,7 @@ export default function SubmissionGrader() { >
- + Q{i + 1} diff --git a/mcp-server/scripts/shoot-demo-widgets.sh b/mcp-server/scripts/shoot-demo-widgets.sh new file mode 100755 index 00000000..09b3d4ef --- /dev/null +++ b/mcp-server/scripts/shoot-demo-widgets.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Render every widget fixture (src/demo-data.ts) to a PNG via the headless +# mcp-use inspector. Requires the dev server running with MCP_DEMO_WIDGETS=1: +# +# cd mcp-server && MCP_DEMO_WIDGETS=1 npm run dev +# ./scripts/shoot-demo-widgets.sh [dark|light] +# +# Output: mcp-server/demo-shots//--.png +set -uo pipefail + +THEME="${1:-dark}" +MCP="${MCP_URL:-http://localhost:3000/mcp}" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +OUT="$ROOT/demo-shots/$THEME" +mkdir -p "$OUT" + +# tool:variant:height — tall widgets get more room so nothing is cut off. +SHOTS=( + "lms_demo_course_dashboard:default:1200" + "lms_demo_course_dashboard:empty:600" + "lms_demo_course_dashboard:long-titles:900" + "lms_demo_course_detail:default:1300" + "lms_demo_course_detail:empty:700" + "lms_demo_lesson_preview:default:1600" + "lms_demo_lesson_preview:bare:700" + "lms_demo_lesson_viewer:default:1600" + "lms_demo_lesson_viewer:completed:900" + "lms_demo_lesson_viewer:broken-mdx:800" + "lms_demo_lesson_viewer:locked:700" + "lms_demo_my_learning:default:1000" + "lms_demo_my_learning:finished:700" + "lms_demo_my_learning:empty:600" + "lms_demo_course_catalog:subscriber:1100" + "lms_demo_course_catalog:no-plan:800" + "lms_demo_course_catalog:empty:600" + "lms_demo_exam_submissions:default:1000" + "lms_demo_exam_submissions:empty:600" + "lms_demo_submission_grader:default:1500" + "lms_demo_my_exam_results:default:1000" + "lms_demo_my_exam_results:empty:600" + "lms_demo_gamification_profile:default:1200" + "lms_demo_gamification_profile:new:700" + "lms_demo_gamification_profile:maxed:900" + "lms_demo_exam_readiness:default:1300" + "lms_demo_exam_readiness:no-signal:800" + "lms_demo_exam_readiness:ready:1100" + "lms_demo_practice_player:all-types:900" + "lms_demo_practice_player:mixed:900" + "lms_demo_flashcards:default:800" + "lms_demo_flashcards:empty:600" + "lms_demo_study_plan:default:1200" + "lms_demo_study_plan:done:800" + "lms_demo_study_plan:empty:700" + "lms_demo_school_overview:default:1400" + "lms_demo_school_overview:new-school:800" + "lms_demo_student_progress_roster:default:1200" + "lms_demo_student_progress_roster:empty:600" + "lms_demo_artifact_sandbox:default:1200" + "lms_demo_landing_page_preview:published:1600" + "lms_demo_landing_page_preview:draft-warnings:900" +) + +i=0 +for entry in "${SHOTS[@]}"; do + IFS=':' read -r tool variant height <<< "$entry" + i=$((i + 1)) + name="${tool#lms_demo_}--${variant}" + printf '[%2d/%d] %s (%s)\n' "$i" "${#SHOTS[@]}" "$name" "$THEME" + npx mcp-use client screenshot \ + --mcp "$MCP" \ + --tool "$tool" "variant=$variant" \ + --width 1100 --height "$height" --theme "$THEME" \ + --output "$OUT/$name.png" >/dev/null 2>&1 \ + || echo " FAILED: $name" +done + +echo "Done → $OUT" diff --git a/mcp-server/src/branding.test.ts b/mcp-server/src/branding.test.ts new file mode 100644 index 00000000..dd60b04b --- /dev/null +++ b/mcp-server/src/branding.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from "vitest"; +import { widget, text } from "mcp-use/server"; +import { brandWidgetResult } from "./register.js"; +import { BRANDING_META_KEY } from "./branding.js"; + +/** + * Tenant theming for widgets. + * + * Branding travels in the tool result's `_meta`, which the widget reads as + * `useWidget().metadata`. These tests pin the parts that are easy to break: + * `_meta` is shared with anything else a tool puts there, so branding must be + * merged rather than assigned, and a caller with no session must come out the + * other side completely untouched rather than erroring. + */ + +const BRANDING = { + name: "Escuela Marea", + logo_url: null, + primary_color: "#0369a1", + secondary_color: "#0891b2", +}; + +/** No auth in the handler context — the unauthenticated path. */ +const NO_AUTH_CTX = {}; + +describe("brandWidgetResult", () => { + it("preserves _meta a tool already set", async () => { + const result = widget({ + props: { a: 1 }, + output: text("hi"), + metadata: { "lms/cursor": "abc123" }, + }) as { _meta?: Record }; + + // What the guard does once a session resolves. + const branded: { _meta: Record } = { + ...result, + _meta: { ...result._meta, [BRANDING_META_KEY]: BRANDING }, + }; + + expect(branded._meta["lms/cursor"]).toBe("abc123"); + expect(branded._meta[BRANDING_META_KEY]).toEqual(BRANDING); + }); + + it("only touches results that render a widget", async () => { + // structuredContent is what distinguishes a widget payload from plain text. + const plain = text("just words") as { structuredContent?: unknown }; + expect(plain.structuredContent).toBeUndefined(); + + const out = await brandWidgetResult(plain, NO_AUTH_CTX); + expect(out).toBe(plain); + expect((plain as { _meta?: Record })._meta?.[BRANDING_META_KEY]).toBeUndefined(); + }); + + it("leaves a widget result untouched when there is no session", async () => { + const result = widget({ props: { a: 1 }, output: text("hi") }); + const before = JSON.stringify(result); + + await brandWidgetResult(result, NO_AUTH_CTX); + + // An unauthenticated caller must not crash the tool or gain a branding key. + expect(JSON.stringify(result)).toBe(before); + expect( + (result as { _meta?: Record })._meta?.[BRANDING_META_KEY] + ).toBeUndefined(); + }); + + it("ignores primitives and null", async () => { + expect(await brandWidgetResult(null, NO_AUTH_CTX)).toBeNull(); + expect(await brandWidgetResult("nope", NO_AUTH_CTX)).toBe("nope"); + }); +}); diff --git a/mcp-server/src/branding.ts b/mcp-server/src/branding.ts new file mode 100644 index 00000000..d7eccd4b --- /dev/null +++ b/mcp-server/src/branding.ts @@ -0,0 +1,79 @@ +import { LmsSession } from "./session.js"; + +/** + * Tenant branding for widgets. + * + * The web app reads `tenants.primary_color` / `secondary_color` and injects + * them as CSS custom properties in the document head + * (`components/tenant/tenant-css-vars-server.tsx`). Widgets render inside a + * host iframe that never sees that markup, so we ship the same values in the + * tool result's `_meta` and let `resources/shared/branding.tsx` apply them. + * + * Injection is central (`installToolGuards` in register.ts), so every + * widget-rendering tool is branded without touching its handler. + */ +export interface TenantBranding { + name: string | null; + logo_url: string | null; + primary_color: string | null; + secondary_color: string | null; +} + +/** `_meta` key. Keep in sync with `BRANDING_META_KEY` in resources/shared/branding.tsx. */ +export const BRANDING_META_KEY = "lms/branding"; + +/** + * Per-tenant cache. Widget tools are read-heavy and branding changes about + * never, so one query per tenant per TTL keeps the injection free. Cached + * misses are stored too — a tenant with no colours must not re-query on every + * single widget call. + */ +const TTL_MS = 5 * 60 * 1000; +const cache = new Map(); + +/** Drop a tenant's cached branding — call after a write that changes it. */ +export function invalidateBranding(tenantId: string): void { + cache.delete(tenantId); +} + +/** + * Fetch the caller's tenant branding. Runs on the caller's RLS-scoped client, + * so a user can only ever read their own tenant's row. Returns `null` when the + * tenant has no colours set, which leaves widgets on the platform default. + */ +export async function getTenantBranding( + session: LmsSession +): Promise { + const tenantId = session.getTenantId(); + const hit = cache.get(tenantId); + if (hit && Date.now() - hit.at < TTL_MS) return hit.value; + + let value: TenantBranding | null = null; + try { + const { data } = await session + .getClient() + .from("tenants") + .select("name, logo_url, primary_color, secondary_color") + .eq("id", tenantId) + .maybeSingle(); + + const primary = (data?.primary_color as string | undefined)?.trim(); + const secondary = (data?.secondary_color as string | undefined)?.trim(); + + // Nothing to theme with — cache the miss so we stop asking. + if (data && (primary || secondary || data.logo_url)) { + value = { + name: (data.name as string | undefined) ?? null, + logo_url: (data.logo_url as string | undefined) ?? null, + primary_color: primary || null, + secondary_color: secondary || null, + }; + } + } catch { + // Branding is decoration. A failed lookup must never fail the tool. + value = null; + } + + cache.set(tenantId, { at: Date.now(), value }); + return value; +} diff --git a/mcp-server/src/demo-data.ts b/mcp-server/src/demo-data.ts new file mode 100644 index 00000000..dab75c5f --- /dev/null +++ b/mcp-server/src/demo-data.ts @@ -0,0 +1,1905 @@ +/** + * Demo fixtures for every MCP App widget in `resources/`. + * + * WHY THIS EXISTS + * Widget props normally come from live Supabase reads inside a tool handler, + * which means seeing a widget requires a seeded database, an OAuth login, and + * a course/lesson/exam that happens to exercise the branch you changed. That + * makes design work on the widgets slow and makes edge states (empty lists, + * locked lessons, null scores, unpublished pages) nearly impossible to look + * at on demand. + * + * These fixtures are hand-written props that satisfy each widget's own + * `propsSchema`, exposed through dev-only `lms_demo_*` tools (see + * `src/tools/demo.ts`) so the MCP inspector can render any widget — including + * its edge states — with zero database and zero auth. + * + * INVARIANTS + * - Fixtures are DATA ONLY. They must never be imported by a production tool. + * - Every variant must satisfy the widget's `propsSchema` exactly. When you + * change a widget schema, update the fixture in the same commit — the + * fixtures double as the only committed example of each widget's payload. + * - Deliberately include awkward values (null descriptions, comma-string + * tags, 0%, ungraded rows) so the widgets get exercised, not flattered. + */ + +export interface WidgetDemoVariant { + /** Short id used as the `variant` tool argument. */ + id: string; + /** Human label shown in the tool description / text output. */ + label: string; + /** Props matching the widget's `propsSchema`. */ + props: Record; + /** Text the model sees alongside the widget. */ + output: string; +} + +export interface WidgetDemo { + /** Directory name under `resources/` — the registered widget name. */ + widget: string; + /** Generated tool name. */ + tool: string; + /** What the widget is for. */ + title: string; + variants: WidgetDemoVariant[]; +} + +// ── Shared sample content ──────────────────────────────────────────────────── + +const LESSON_MDX = `Los *hooks* de React resuelven un problema concreto: compartir lógica con estado entre componentes sin heredar ni envolver. + +## Por qué existen los hooks + +Antes de 2019 la única forma de reutilizar lógica con estado era el patrón *render props* o los HOCs. Ambos funcionan, pero anidan el árbol de componentes hasta volverlo ilegible. + + +Los hooks solo se llaman en el nivel superior de un componente o de otro hook. Nunca dentro de condicionales, bucles o funciones anidadas. + + +### Los tres hooks que usarás el 90% del tiempo + +| Hook | Para qué sirve | Se ejecuta | +| --- | --- | --- | +| \`useState\` | Estado local del componente | En cada render | +| \`useEffect\` | Sincronizar con sistemas externos | Después del render | +| \`useMemo\` | Cachear un cálculo costoso | Cuando cambian las dependencias | + +\`\`\`tsx +export function Contador() { + const [n, setN] = useState(0) + return +} +\`\`\` + + +\`useEffect\` sin array de dependencias corre en **cada** render. Es la causa número uno de bucles infinitos de fetch en los proyectos de este curso. + + +## Dependencias, el detalle que cuesta + + + +Empieza sin pensar en dependencias: qué tiene que pasar y cuándo. + + +\`react-hooks/exhaustive-deps\` casi siempre tiene razón. + + +Una dependencia que no quieres declarar suele ser código que debería vivir fuera del efecto. + + + + +Un array vacío significa "no dependo de nada", así que React lo corre una sola vez tras el montaje. + + +> El estado no es una variable, es una instantánea. Cada render ve su propia copia. + + +El bug estaba en \`setItems(items.push(nuevo))\`: \`push\` muta el array y devuelve la longitud, así que el estado quedaba en un número. + + +Cuando termines, marca la lección como completada y pasa a **Efectos y limpieza**.`; + +/** + * Lesson content that MDX cannot parse — kept deliberately. + * + * `` children are parsed as MDX, so a code sample whose first line + * starts at column 0 with `export`/`import` is read as an ESM statement and + * handed to acorn, which chokes on the closing ``. One bad snippet + * fails the WHOLE document, and the renderer falls back to dumping raw source + * at the student. This is the exact shape a teacher produces by pasting a + * module into the documented `` form, + * so the fallback state needs to stay inspectable. + */ +const BROKEN_LESSON_MDX = `Un módulo de ejemplo, pegado tal cual dentro de un bloque de código. + + +export function Contador() { + const [n, setN] = useState(0) + return +} + + +El resto de la lección ya no se renderiza: **todo** el documento cae al modo texto plano.`; + +const ARTIFACT_HTML = ` + + + + + + +

Ordena el ciclo de vida de una petición

+

Haz clic en los pasos en el orden correcto.

+
+ + + + +
+
+ + +`; + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +export const WIDGET_DEMOS: WidgetDemo[] = [ + // ───────────────────────────────────────────────────────── course-dashboard + { + widget: "course-dashboard", + tool: "lms_demo_course_dashboard", + title: "Teacher course grid (lms_list_courses)", + variants: [ + { + id: "default", + label: "Mixed statuses, 6 courses", + output: "Found 6 course(s).", + props: { + status: "all", + total: 6, + courses: [ + { + id: 101, + title: "React 19 en la práctica", + description: + "Server components, acciones de formulario y el nuevo compilador, construyendo una app real de principio a fin.", + status: "published", + tags: ["react", "frontend", "avanzado"], + lesson_count: 24, + enrollment_count: 318, + created_at: "2026-02-11T09:20:00Z", + updated_at: "2026-07-19T16:04:00Z", + }, + { + id: 102, + title: "TypeScript de cero a estricto", + description: + "Tipos, genéricos e inferencia hasta poder activar strict en un proyecto heredado sin romperlo.", + status: "published", + // Comma-string tags: what older rows actually store. + tags: "typescript, fundamentos, backend", + lesson_count: 18, + enrollment_count: 412, + created_at: "2025-11-03T14:00:00Z", + updated_at: "2026-07-24T11:47:00Z", + }, + { + id: 103, + title: "Postgres y Row Level Security", + description: + "Diseñar esquemas multi-tenant que no filtren datos: políticas, claims de JWT y pruebas de aislamiento.", + status: "published", + tags: ["postgres", "seguridad", "supabase"], + lesson_count: 15, + enrollment_count: 97, + created_at: "2026-04-28T08:15:00Z", + updated_at: "2026-07-26T19:31:00Z", + }, + { + id: 104, + title: "Diseño de APIs REST", + // Null description: the widget must not print "null". + description: null, + status: "draft", + tags: ["api", "backend"], + lesson_count: 6, + enrollment_count: 0, + created_at: "2026-07-12T10:05:00Z", + updated_at: "2026-07-25T09:12:00Z", + }, + { + id: 105, + title: "Fundamentos de accesibilidad web", + description: + "WCAG AA sin dogmas: cómo auditar, qué priorizar y cómo justificar el trabajo ante producto.", + status: "draft", + tags: null, + lesson_count: 9, + enrollment_count: 0, + created_at: "2026-06-30T13:40:00Z", + updated_at: "2026-07-21T17:58:00Z", + }, + { + id: 106, + title: "jQuery para aplicaciones modernas", + description: + "Curso retirado. Se mantiene por los alumnos con acceso vitalicio; sustituido por «React 19 en la práctica».", + status: "archived", + tags: ["legacy"], + lesson_count: 31, + enrollment_count: 1204, + created_at: "2023-01-18T07:00:00Z", + updated_at: "2026-01-09T12:00:00Z", + }, + ], + }, + }, + { + id: "empty", + label: "No courses yet (first-run state)", + output: "No courses found.", + props: { status: "all", total: 0, courses: [] }, + }, + { + id: "long-titles", + label: "Overflow stress test", + output: "Found 2 course(s).", + props: { + status: "published", + total: 2, + courses: [ + { + id: 201, + title: + "Programa intensivo de ingeniería de plataforma: infraestructura como código, observabilidad y confiabilidad para equipos que ya operan en producción", + description: + "Un temario deliberadamente largo para comprobar que la tarjeta trunca el texto en lugar de empujar la cuadrícula, incluso cuando el autor escribe una descripción entera sin un solo punto y aparte porque así es como la gente rellena los formularios en la vida real.", + status: "published", + tags: [ + "infraestructura", + "observabilidad", + "sre", + "kubernetes", + "terraform", + "on-call", + "postmortems", + ], + lesson_count: 96, + enrollment_count: 12480, + created_at: "2026-03-02T09:00:00Z", + updated_at: "2026-07-26T22:10:00Z", + }, + { + id: 202, + title: "Git", + description: "Corto.", + status: "published", + tags: ["git"], + lesson_count: 1, + enrollment_count: 3, + created_at: "2026-07-01T09:00:00Z", + updated_at: "2026-07-01T09:00:00Z", + }, + ], + }, + }, + ], + }, + + // ───────────────────────────────────────────────────────────── course-detail + { + widget: "course-detail", + tool: "lms_demo_course_detail", + title: "Course detail with lessons + exams (lms_get_course)", + variants: [ + { + id: "default", + label: "Published course, 8 lessons, 2 exams", + output: "Course 101 — React 19 en la práctica.", + props: { + course: { + id: 101, + title: "React 19 en la práctica", + description: + "Server components, acciones de formulario y el nuevo compilador, construyendo una app real de principio a fin.", + status: "published", + tags: ["react", "frontend", "avanzado"], + require_sequential_completion: true, + enrollment_count: 318, + created_at: "2026-02-11T09:20:00Z", + }, + lessons: [ + { id: 5001, title: "Qué cambia realmente en React 19", sequence: 1, status: "published" }, + { id: 5002, title: "Server components: el modelo mental", sequence: 2, status: "published" }, + { id: 5003, title: "Hooks: estado y efectos", sequence: 3, status: "published" }, + { id: 5004, title: "Efectos y limpieza", sequence: 4, status: "published" }, + { id: 5005, title: "Acciones de formulario y useActionState", sequence: 5, status: "published" }, + { id: 5006, title: "Suspense y streaming", sequence: 6, status: "published" }, + { id: 5007, title: "El compilador: qué deja de hacer falta", sequence: 7, status: "draft" }, + { id: 5008, title: "Migrar una app de React 18", sequence: 8, status: "draft" }, + ], + exams: [ + { + id: 9001, + title: "Evaluación parcial: modelo de renderizado", + date: "2026-08-04T15:00:00Z", + duration: 45, + status: "published", + }, + { + id: 9002, + title: "Examen final del curso", + date: null, + duration: 90, + status: "draft", + }, + ], + }, + }, + { + id: "empty", + label: "Brand-new course, nothing authored yet", + output: "Course 104 — Diseño de APIs REST (draft, no content yet).", + props: { + course: { + id: 104, + title: "Diseño de APIs REST", + description: null, + status: "draft", + tags: null, + require_sequential_completion: false, + enrollment_count: 0, + created_at: "2026-07-12T10:05:00Z", + }, + lessons: [], + exams: [], + }, + }, + ], + }, + + // ──────────────────────────────────────────────────────────── lesson-preview + { + widget: "lesson-preview", + tool: "lms_demo_lesson_preview", + title: "Teacher lesson preview with resources (lms_get_lesson)", + variants: [ + { + id: "default", + label: "Rich MDX lesson with video + 3 resources", + output: "Lesson 5003 — Hooks: estado y efectos.", + props: { + lesson: { + id: 5003, + title: "Hooks: estado y efectos", + description: + "Por qué existen los hooks, las dos reglas que no se pueden romper y los tres que cubren casi todo.", + video_url: "https://www.youtube.com/watch?v=dpw9EHDh2bM", + embed_code: null, + content: LESSON_MDX, + status: "published", + sequence: 3, + }, + resources: [ + { id: 7001, file_name: "hooks-cheatsheet.pdf", file_size: 284_512, mime_type: "application/pdf" }, + { id: 7002, file_name: "ejercicios-hooks.zip", file_size: 1_884_204, mime_type: "application/zip" }, + { id: 7003, file_name: "diagrama-ciclo-de-vida.png", file_size: null, mime_type: null }, + ], + }, + }, + { + id: "bare", + label: "Draft lesson: no content, no video, no resources", + output: "Lesson 5007 — El compilador (draft, empty).", + props: { + lesson: { + id: 5007, + title: "El compilador: qué deja de hacer falta", + description: null, + video_url: null, + embed_code: null, + content: null, + status: "draft", + sequence: 7, + }, + resources: [], + }, + }, + ], + }, + + // ───────────────────────────────────────────────────────────── lesson-viewer + { + widget: "lesson-viewer", + tool: "lms_demo_lesson_viewer", + title: "Student lesson reader with mark-complete (lms_view_lesson)", + variants: [ + { + id: "default", + label: "Unlocked, not yet completed", + output: "Lesson 5003 of React 19 en la práctica.", + props: { + lesson: { + id: 5003, + course_id: 101, + title: "Hooks: estado y efectos", + description: + "Por qué existen los hooks, las dos reglas que no se pueden romper y los tres que cubren casi todo.", + summary: + "Al terminar sabrás elegir entre useState, useEffect y useMemo, y detectar por qué un efecto se repite.", + content: LESSON_MDX, + video_url: "https://www.youtube.com/watch?v=dpw9EHDh2bM", + embed_code: null, + sequence: 3, + }, + course_title: "React 19 en la práctica", + completed: false, + locked: false, + locked_by: null, + }, + }, + { + id: "completed", + label: "Already completed", + output: "Lesson 5002 — already completed.", + props: { + lesson: { + id: 5002, + course_id: 101, + title: "Server components: el modelo mental", + description: "Dónde corre cada cosa y por qué el árbol se parte en dos.", + summary: null, + content: + "Un server component no se rehidrata: se serializa el resultado, no el componente.\n\nSi necesitas `useState`, ese componente vive en el cliente. No hay término medio.\n\nEso es todo por hoy.", + video_url: null, + embed_code: null, + sequence: 2, + }, + course_title: "React 19 en la práctica", + completed: true, + locked: false, + locked_by: null, + }, + }, + { + id: "broken-mdx", + label: "Unparseable MDX — raw-source fallback", + output: "Lesson 5009 — content could not be parsed as MDX.", + props: { + lesson: { + id: 5009, + course_id: 101, + title: "Un contador con estado", + description: "Lección cuyo bloque de código rompe el parseo MDX.", + summary: null, + content: BROKEN_LESSON_MDX, + video_url: null, + embed_code: null, + sequence: 9, + }, + course_title: "React 19 en la práctica", + completed: false, + locked: false, + locked_by: null, + }, + }, + { + id: "locked", + label: "Locked by sequential completion", + output: "Lesson 5005 is locked until lesson 5004 is completed.", + props: { + lesson: { + id: 5005, + course_id: 101, + title: "Acciones de formulario y useActionState", + description: "Formularios sin estado manual ni handlers de submit.", + summary: null, + content: null, + video_url: null, + embed_code: null, + sequence: 5, + }, + course_title: "React 19 en la práctica", + completed: false, + locked: true, + locked_by: { id: 5004, title: "Efectos y limpieza" }, + }, + }, + ], + }, + + // ────────────────────────────────────────────────────────────── my-learning + { + widget: "my-learning", + tool: "lms_demo_my_learning", + title: "Student learning dashboard (lms_my_learning)", + variants: [ + { + id: "default", + label: "3 courses in progress", + output: "You are enrolled in 3 course(s).", + props: { + total: 3, + average_progress: 47, + courses: [ + { + id: 101, + title: "React 19 en la práctica", + description: "Server components, acciones de formulario y el nuevo compilador.", + thumbnail_url: null, + enrolled_at: "2026-05-02T10:12:00Z", + lessons_total: 24, + lessons_completed: 17, + progress: 71, + next_lesson: { id: 5018, title: "Suspense en listas largas", sequence: 18 }, + }, + { + id: 102, + title: "TypeScript de cero a estricto", + description: "Tipos, genéricos e inferencia hasta activar strict sin romper nada.", + thumbnail_url: null, + enrolled_at: "2026-06-18T08:44:00Z", + lessons_total: 18, + lessons_completed: 12, + progress: 67, + next_lesson: { id: 6013, title: "Tipos condicionales", sequence: 13 }, + }, + { + id: 103, + title: "Postgres y Row Level Security", + description: null, + thumbnail_url: null, + enrolled_at: "2026-07-24T19:03:00Z", + lessons_total: 15, + lessons_completed: 0, + // Just enrolled: 0% and lesson 1 is next. + progress: 0, + next_lesson: { id: 6501, title: "Qué es realmente una política RLS", sequence: 1 }, + }, + ], + }, + }, + { + id: "finished", + label: "One course fully completed", + output: "You are enrolled in 1 course.", + props: { + total: 1, + average_progress: 100, + courses: [ + { + id: 102, + title: "TypeScript de cero a estricto", + description: "Tipos, genéricos e inferencia hasta activar strict sin romper nada.", + thumbnail_url: null, + enrolled_at: "2026-01-08T08:44:00Z", + lessons_total: 18, + lessons_completed: 18, + progress: 100, + next_lesson: null, + }, + ], + }, + }, + { + id: "empty", + label: "Not enrolled in anything", + output: "You are not enrolled in any courses yet.", + props: { total: 0, average_progress: 0, courses: [] }, + }, + ], + }, + + // ─────────────────────────────────────────────────────────── course-catalog + { + widget: "course-catalog", + tool: "lms_demo_course_catalog", + title: "Student catalog with access badges (lms_browse_catalog)", + variants: [ + { + id: "subscriber", + label: "Student on a plan", + output: "6 course(s) in the catalog.", + props: { + total: 6, + has_subscription: true, + courses: [ + { + id: 101, + title: "React 19 en la práctica", + description: "Server components, acciones de formulario y el nuevo compilador.", + thumbnail_url: null, + tags: ["react", "frontend"], + lesson_count: 24, + enrolled: true, + has_access: true, + covered_by_plan: true, + }, + { + id: 102, + title: "TypeScript de cero a estricto", + description: "Tipos, genéricos e inferencia hasta activar strict sin romper nada.", + thumbnail_url: null, + tags: "typescript, fundamentos", + lesson_count: 18, + enrolled: true, + has_access: true, + covered_by_plan: true, + }, + { + id: 103, + title: "Postgres y Row Level Security", + description: "Esquemas multi-tenant que no filtran datos.", + thumbnail_url: null, + tags: ["postgres", "seguridad"], + lesson_count: 15, + enrolled: false, + has_access: true, + covered_by_plan: true, + }, + { + id: 107, + title: "Arquitectura de sistemas distribuidos", + description: + "Consistencia, particiones y colas: cuándo cada patrón es la respuesta correcta y cuándo es sobreingeniería.", + thumbnail_url: null, + tags: ["arquitectura", "avanzado"], + lesson_count: 22, + enrolled: false, + // Sold separately: on a plan, but this one is not covered. + has_access: false, + covered_by_plan: false, + }, + { + id: 108, + title: "Introducción a la programación", + description: "Gratis para toda la escuela. Variables, bucles y funciones desde cero.", + thumbnail_url: null, + tags: ["principiantes", "gratis"], + lesson_count: 12, + enrolled: false, + has_access: true, + covered_by_plan: false, + }, + { + id: 109, + title: "Entrevistas técnicas: algoritmos", + description: null, + thumbnail_url: null, + tags: null, + lesson_count: 30, + enrolled: false, + has_access: false, + covered_by_plan: false, + }, + ], + }, + }, + { + id: "no-plan", + label: "Student with no subscription", + output: "3 course(s) in the catalog.", + props: { + total: 3, + has_subscription: false, + courses: [ + { + id: 101, + title: "React 19 en la práctica", + description: "Server components, acciones de formulario y el nuevo compilador.", + thumbnail_url: null, + tags: ["react", "frontend"], + lesson_count: 24, + enrolled: false, + has_access: false, + covered_by_plan: false, + }, + { + id: 108, + title: "Introducción a la programación", + description: "Gratis para toda la escuela.", + thumbnail_url: null, + tags: ["gratis"], + lesson_count: 12, + enrolled: true, + has_access: true, + covered_by_plan: false, + }, + { + id: 109, + title: "Entrevistas técnicas: algoritmos", + description: "Patrones de problemas, no soluciones memorizadas.", + thumbnail_url: null, + tags: ["entrevistas"], + lesson_count: 30, + enrolled: false, + has_access: false, + covered_by_plan: false, + }, + ], + }, + }, + { + id: "empty", + label: "Empty catalog", + output: "No courses available yet.", + props: { total: 0, has_subscription: false, courses: [] }, + }, + ], + }, + + // ──────────────────────────────────────────────────────────── exam-submissions + { + widget: "exam-submissions", + tool: "lms_demo_exam_submissions", + title: "Teacher submission list (lms_list_exam_submissions)", + variants: [ + { + id: "default", + label: "8 submissions, mixed review status", + output: "8 submission(s) for exam 9001.", + props: { + exam_id: 9001, + total: 8, + submissions: [ + { id: 4101, student_name: "Alicia Nguyen", score: 92, submission_date: "2026-07-24T14:02:00Z", review_status: "reviewed" }, + { id: 4102, student_name: "Bruno Salas", score: 78, submission_date: "2026-07-24T14:11:00Z", review_status: "reviewed" }, + { id: 4103, student_name: "Camila Rojas", score: 64, submission_date: "2026-07-24T14:19:00Z", review_status: "ai_reviewed" }, + { id: 4104, student_name: "Diego Fernández", score: null, submission_date: "2026-07-24T14:26:00Z", review_status: "pending" }, + { id: 4105, student_name: "Emma Whitfield", score: 100, submission_date: "2026-07-25T09:40:00Z", review_status: "reviewed" }, + { id: 4106, student_name: "Farid Haddad", score: 41, submission_date: "2026-07-25T10:03:00Z", review_status: "ai_reviewed" }, + { id: 4107, student_name: "Grace Okoye", score: null, submission_date: "2026-07-26T08:12:00Z", review_status: null }, + { id: 4108, student_name: "Hugo Marín", score: 87, submission_date: "2026-07-26T11:55:00Z", review_status: "reviewed" }, + ], + }, + }, + { + id: "empty", + label: "Nobody has taken the exam", + output: "No submissions yet for exam 9002.", + props: { exam_id: 9002, total: 0, submissions: [] }, + }, + ], + }, + + // ─────────────────────────────────────────────────────────── submission-grader + { + widget: "submission-grader", + tool: "lms_demo_submission_grader", + title: "Grade one submission (lms_get_submission → lms_grade_submission)", + variants: [ + { + id: "default", + label: "AI-graded, awaiting teacher review", + output: "Submission 4103 by Camila Rojas — 64/100, ai_reviewed.", + props: { + submission: { + id: 4103, + exam_id: 9001, + exam_title: "Evaluación parcial: modelo de renderizado", + student_id: "5f2a91c4-1d3e-4f88-9b0a-7c6d5e4f3a21", + student_name: "Camila Rojas", + score: 64, + feedback: + "Dominas la parte conceptual de server components, pero las respuestas sobre efectos mezclan el momento de ejecución con el orden de limpieza. Repasa la lección 4 antes del final.", + review_status: "ai_reviewed", + date: "2026-07-24T14:19:00Z", + }, + questions: [ + { + question_id: 8801, + text: "¿Cuál de estas afirmaciones sobre los server components es correcta?", + type: "multiple_choice", + options: [ + { text: "Se rehidratan en el cliente igual que los componentes clásicos", is_correct: false }, + { text: "Su resultado se serializa; el componente nunca llega al navegador", is_correct: true }, + { text: "Solo pueden usarse en rutas estáticas", is_correct: false }, + { text: "Necesitan useEffect para leer datos", is_correct: false }, + ], + student_answer: "Su resultado se serializa; el componente nunca llega al navegador", + points_earned: 20, + points_possible: 20, + is_correct: true, + ai_feedback: null, + ai_confidence: null, + is_overridden: false, + }, + { + question_id: 8802, + text: "Verdadero o falso: un efecto con array de dependencias vacío se ejecuta en cada render.", + type: "true_false", + options: [ + { text: "Verdadero", is_correct: false }, + { text: "Falso", is_correct: true }, + ], + student_answer: "Falso", + points_earned: 10, + points_possible: 10, + is_correct: true, + ai_feedback: null, + ai_confidence: null, + is_overridden: false, + }, + { + question_id: 8803, + text: "Explica con tus palabras por qué el estado se comporta como una instantánea dentro de un render.", + type: "free_text", + options: [], + student_answer: + "Porque cuando el componente se vuelve a ejecutar, las variables se crean otra vez, así que el valor que lee el handler es el que existía cuando se creó ese render, no el más nuevo.", + points_earned: 24, + points_possible: 30, + is_correct: null, + ai_feedback: + "Idea correcta y bien explicada. Falta mencionar que por eso las actualizaciones basadas en el valor anterior deben usar la forma funcional de setState; sin eso la respuesta queda a medias.", + ai_confidence: 0.82, + is_overridden: false, + }, + { + question_id: 8804, + text: "Describe un caso en el que useMemo empeora el rendimiento.", + type: "free_text", + options: [], + student_answer: + "Cuando el cálculo es barato, porque igual tienes que comparar dependencias.", + points_earned: 6, + points_possible: 25, + is_correct: null, + ai_feedback: + "Respuesta demasiado breve para el valor de la pregunta. La comparación de dependencias es solo una parte; falta el coste de memoria y el de mantener el array correcto.", + ai_confidence: 0.41, + // Teacher already bumped this one by hand. + is_overridden: true, + }, + { + question_id: 8805, + text: "Ordena las fases: montaje, efecto, limpieza, render.", + type: "order", + options: [], + student_answer: null, + points_earned: null, + points_possible: 15, + is_correct: null, + ai_feedback: null, + ai_confidence: null, + is_overridden: false, + }, + ], + summary: { + question_count: 5, + graded_count: 4, + total_points_earned: 60, + total_points_possible: 100, + }, + }, + }, + ], + }, + + // ────────────────────────────────────────────────────────── my-exam-results + { + widget: "my-exam-results", + tool: "lms_demo_my_exam_results", + title: "Student exam results (lms_my_exam_results)", + variants: [ + { + id: "default", + label: "4 results, one still pending", + output: "You have 4 exam result(s), averaging 79.", + props: { + total: 4, + average_score: 79, + results: [ + { + submission_id: 4108, + exam_id: 9001, + exam_title: "Evaluación parcial: modelo de renderizado", + course_title: "React 19 en la práctica", + score: 87, + feedback: + "Muy sólido en el modelo de renderizado. Perdiste puntos solo en la pregunta de limpieza de efectos: repasa qué devuelve la función de un efecto y cuándo se llama.", + review_status: "reviewed", + submitted_at: "2026-07-26T11:55:00Z", + }, + { + submission_id: 3902, + exam_id: 8801, + exam_title: "Tipos genéricos", + course_title: "TypeScript de cero a estricto", + score: 94, + feedback: "Impecable. Nada que corregir.", + review_status: "reviewed", + submitted_at: "2026-07-10T16:20:00Z", + }, + { + submission_id: 3711, + exam_id: 8802, + exam_title: "Inferencia y estrechamiento de tipos", + course_title: "TypeScript de cero a estricto", + score: 56, + feedback: + "Confundes el estrechamiento por type guard con las aserciones. La diferencia importa: una la comprueba el compilador, la otra la prometes tú.", + review_status: "ai_reviewed", + submitted_at: "2026-06-28T10:02:00Z", + }, + { + submission_id: 4210, + exam_id: 9003, + exam_title: "Diagnóstico inicial de Postgres", + course_title: null, + score: null, + feedback: null, + review_status: "pending", + submitted_at: "2026-07-27T08:31:00Z", + }, + ], + }, + }, + { + id: "empty", + label: "No exams taken", + output: "You have not submitted any exams yet.", + props: { total: 0, average_score: null, results: [] }, + }, + ], + }, + + // ────────────────────────────────────────────────────── gamification-profile + { + widget: "gamification-profile", + tool: "lms_demo_gamification_profile", + title: "XP, level, streak and achievements (lms_my_gamification)", + variants: [ + { + id: "default", + label: "Level 7, 12-day streak, 6 achievements", + output: "Level 7 · 4,820 XP · 12-day streak.", + props: { + has_profile: true, + total_xp: 4820, + level: 7, + level_title: "Constructor", + level_icon: "🛠️", + next_level: { level: 8, min_xp: 5500 }, + xp_into_level: 320, + xp_needed: 680, + coins: 138, + current_streak: 12, + longest_streak: 29, + rank: 4, + participants: 217, + achievements: [ + { + slug: "first-lesson", + title: "Primera lección", + description: "Completaste tu primera lección.", + tier: "bronze", + icon: "🎓", + xp_reward: 50, + earned_at: "2026-05-02T10:40:00Z", + }, + { + slug: "week-streak", + title: "Semana perfecta", + description: "Siete días seguidos estudiando.", + tier: "silver", + icon: "🔥", + xp_reward: 150, + earned_at: "2026-05-09T21:12:00Z", + }, + { + slug: "exam-ace", + title: "Nota perfecta", + description: "Sacaste 100 en un examen.", + tier: "gold", + icon: "🏆", + xp_reward: 400, + earned_at: "2026-06-14T18:03:00Z", + }, + { + slug: "night-owl", + title: "Búho nocturno", + description: "Diez lecciones completadas después de medianoche.", + tier: "bronze", + icon: "🦉", + xp_reward: 75, + earned_at: "2026-06-22T02:47:00Z", + }, + { + slug: "helper", + title: "Buena gente", + description: null, + tier: null, + icon: null, + xp_reward: null, + earned_at: "2026-07-01T13:20:00Z", + }, + { + slug: "course-complete", + title: "Curso terminado", + description: "Completaste todas las lecciones de un curso.", + tier: "gold", + icon: "✅", + xp_reward: 500, + earned_at: "2026-07-19T17:35:00Z", + }, + ], + }, + }, + { + id: "new", + label: "Brand-new student, no profile row yet", + output: "No gamification profile yet — complete a lesson to start earning XP.", + props: { + has_profile: false, + total_xp: 0, + level: 1, + level_title: null, + level_icon: null, + next_level: { level: 2, min_xp: 100 }, + xp_into_level: 0, + xp_needed: 100, + coins: 0, + current_streak: 0, + longest_streak: 0, + rank: null, + participants: 217, + achievements: [], + }, + }, + { + id: "maxed", + label: "Top of the leaderboard, no next level", + output: "Level 20 · 128,400 XP · rank 1 of 217.", + props: { + has_profile: true, + total_xp: 128_400, + level: 20, + level_title: "Leyenda", + level_icon: "👑", + next_level: null, + xp_into_level: 8400, + xp_needed: null, + coins: 9042, + current_streak: 214, + longest_streak: 214, + rank: 1, + participants: 217, + achievements: [ + { + slug: "year-streak", + title: "Un año sin fallar", + description: "365 días consecutivos de actividad.", + tier: "platinum", + icon: "💎", + xp_reward: 5000, + earned_at: "2026-07-20T09:00:00Z", + }, + ], + }, + }, + ], + }, + + // ───────────────────────────────────────────────────────────── exam-readiness + { + widget: "exam-readiness", + tool: "lms_demo_exam_readiness", + title: "Readiness score + topic heatmap (lms_get_exam_readiness)", + variants: [ + { + id: "default", + label: "68% ready, upcoming exam, 6 topics", + output: "Readiness for «Evaluación parcial»: 68/100.", + props: { + course_id: 101, + course_title: "React 19 en la práctica", + exam: { + exam_id: 9001, + title: "Evaluación parcial: modelo de renderizado", + exam_date: "2026-08-04T15:00:00Z", + }, + readiness: 68, + components: { + exam_history: 74, + practice: 61, + lesson_coverage: 71, + weights: { exam_history: 0.4, practice: 0.35, lesson_coverage: 0.25 }, + }, + formula: + "readiness = 0.40 × historial de exámenes + 0.35 × práctica + 0.25 × cobertura de lecciones", + topics: [ + { label: "Server components", mastery: 88, source: "exam", evidence: "3 preguntas acertadas de 3" }, + { label: "Hooks: estado", mastery: 79, source: "practice", evidence: "22 de 28 intentos correctos" }, + { label: "Efectos y limpieza", mastery: 44, source: "practice", evidence: "9 de 21 intentos correctos" }, + { label: "Suspense y streaming", mastery: 35, source: "exam", evidence: "1 pregunta acertada de 3" }, + { label: "Acciones de formulario", mastery: 62, source: "practice", evidence: "13 de 21 intentos correctos" }, + { label: "Compilador de React", mastery: 12, source: "practice", evidence: "1 de 8 intentos correctos" }, + ], + lessons: { completed: 17, total: 24 }, + }, + }, + { + id: "no-signal", + label: "No data yet — readiness null", + output: "Not enough signal to compute readiness yet.", + props: { + course_id: 103, + course_title: "Postgres y Row Level Security", + exam: null, + readiness: null, + components: { + exam_history: null, + practice: null, + lesson_coverage: 0, + weights: { exam_history: 0.4, practice: 0.35, lesson_coverage: 0.25 }, + }, + formula: + "readiness = 0.40 × historial de exámenes + 0.35 × práctica + 0.25 × cobertura de lecciones", + topics: [], + lessons: { completed: 0, total: 15 }, + }, + }, + { + id: "ready", + label: "94% — exam tomorrow", + output: "Readiness for «Examen final»: 94/100.", + props: { + course_id: 102, + course_title: "TypeScript de cero a estricto", + exam: { + exam_id: 8803, + title: "Examen final de TypeScript", + exam_date: "2026-07-28T15:00:00Z", + }, + readiness: 94, + components: { + exam_history: 96, + practice: 91, + lesson_coverage: 94, + weights: { exam_history: 0.4, practice: 0.35, lesson_coverage: 0.25 }, + }, + formula: + "readiness = 0.40 × historial de exámenes + 0.35 × práctica + 0.25 × cobertura de lecciones", + topics: [ + { label: "Genéricos", mastery: 97, source: "exam", evidence: "8 preguntas acertadas de 8" }, + { label: "Tipos condicionales", mastery: 90, source: "practice", evidence: "27 de 30 intentos correctos" }, + { label: "Inferencia", mastery: 84, source: "practice", evidence: "21 de 25 intentos correctos" }, + ], + lessons: { completed: 17, total: 18 }, + }, + }, + ], + }, + + // ──────────────────────────────────────────────────────────── practice-player + { + widget: "practice-player", + tool: "lms_demo_practice_player", + title: "Interactive practice quiz (lms_practice_quiz)", + variants: [ + { + id: "all-types", + label: "One question of each of the 6 types", + output: "Practice session on «Hooks» — 6 questions.", + props: { + topic: "Hooks: estado y efectos", + mode: "focused", + course_id: 101, + lesson_id: 5003, + source_exercise_id: 3301, + questions: [ + { + id: "q1", + type: "multiple_choice", + prompt: "¿Qué devuelve la función que pasas a useEffect?", + options: [ + "El valor del estado actualizado", + "Una función de limpieza que React ejecuta antes del siguiente efecto", + "Nada, siempre debe ser void", + "Una promesa que React espera", + ], + correct: "Una función de limpieza que React ejecuta antes del siguiente efecto", + explanation: + "El valor de retorno de un efecto es su limpieza. React la llama al desmontar y antes de volver a ejecutar el efecto.", + }, + { + id: "q2", + type: "true_false", + prompt: "Los hooks pueden llamarse dentro de un if siempre que la condición sea constante.", + correct: false, + explanation: + "No. React identifica cada hook por su orden de llamada; una condicional puede alterar ese orden entre renders.", + }, + { + id: "q3", + type: "fill_blank", + prompt: "Para cachear un cálculo costoso entre renders se usa el hook ______.", + correct: "useMemo", + explanation: "useMemo recalcula solo cuando cambian sus dependencias.", + }, + { + id: "q4", + type: "match", + prompt: "Empareja cada hook con su propósito.", + pairs: [ + { left: "useState", right: "Estado local del componente" }, + { left: "useEffect", right: "Sincronizar con un sistema externo" }, + { left: "useMemo", right: "Cachear un cálculo" }, + { left: "useRef", right: "Guardar un valor sin provocar render" }, + ], + explanation: "useRef es el único de los cuatro que no dispara un nuevo render al cambiar.", + }, + { + id: "q5", + type: "order", + prompt: "Ordena lo que ocurre al montar un componente con un efecto.", + sequence: [ + "React ejecuta el cuerpo del componente", + "React aplica los cambios al DOM", + "El navegador pinta", + "React ejecuta el efecto", + ], + explanation: "Los efectos corren después del pintado; por eso no bloquean la primera imagen.", + }, + { + id: "q6", + type: "free_text", + prompt: + "Un compañero dice que useEffect «se ejecuta cuando cambia el estado». ¿Qué le corregirías?", + explanation: + "Se ejecuta después de un render en el que alguna dependencia declarada cambió — no por cambiar el estado en sí.", + }, + ], + }, + }, + { + id: "mixed", + label: "Interleaved session across topics", + output: "Mixed practice — 4 questions across 3 topics.", + props: { + topic: "Repaso mezclado", + mode: "mixed", + course_id: 101, + lesson_id: null, + source_exercise_id: null, + questions: [ + { + id: "m1", + type: "multiple_choice", + topic: "Server components", + prompt: "¿Dónde se ejecuta un server component?", + options: ["En el navegador", "En el servidor", "En ambos", "En un web worker"], + correct: "En el servidor", + explanation: "Por eso puede leer la base de datos directamente y nunca llega su código al cliente.", + }, + { + id: "m2", + type: "true_false", + topic: "Efectos y limpieza", + prompt: "La limpieza de un efecto se ejecuta también antes de cada re-ejecución del efecto.", + correct: true, + explanation: "No solo al desmontar: React limpia el efecto anterior antes de aplicar el nuevo.", + }, + { + id: "m3", + type: "fill_blank", + topic: "Acciones de formulario", + prompt: "El hook que expone el estado de una acción de formulario es ______.", + correct: "useActionState", + explanation: "Devuelve el estado, la acción envuelta y un booleano de pendiente.", + }, + { + id: "m4", + type: "free_text", + topic: "Suspense", + prompt: "¿Qué problema resuelve Suspense que no resolvía un estado `isLoading` manual?", + }, + ], + }, + }, + ], + }, + + // ──────────────────────────────────────────────────────────────── flashcards + { + widget: "flashcards", + tool: "lms_demo_flashcards", + title: "FSRS review session (lms_get_due_reviews)", + variants: [ + { + id: "default", + label: "6 cards due out of 23", + output: "6 card(s) in this session, 23 due in total.", + props: { + total_due: 23, + cards: [ + { + id: 2201, + front: "¿Qué hace `useEffect` sin array de dependencias?", + back: "Se ejecuta después de **cada** render. Es la causa habitual de bucles infinitos de fetch.", + repetitions: 4, + interval_days: 9, + }, + { + id: 2202, + front: "¿Qué es una política RLS?", + back: "Una expresión SQL que Postgres añade a cada consulta sobre la tabla, por rol y por operación. Si devuelve falso, la fila no existe para ese usuario.", + repetitions: 1, + interval_days: 1, + }, + { + id: 2203, + front: "Diferencia entre `unknown` y `any`", + back: "`any` desactiva el chequeo; `unknown` obliga a estrechar el tipo antes de usarlo. `unknown` es el `any` seguro.", + repetitions: 7, + interval_days: 34, + }, + { + id: 2204, + front: "¿Cuándo se serializa un server component?", + back: "Al renderizar en el servidor: viaja el resultado (el árbol), nunca el código del componente.", + repetitions: 0, + interval_days: 0, + }, + { + id: 2205, + front: "¿Qué devuelve `useActionState`?", + back: "Una tupla: `[state, formAction, isPending]`.", + repetitions: 2, + interval_days: 3, + }, + { + id: 2206, + front: + "Explica por qué `setItems(items.push(nuevo))` deja el estado en un número en lugar de un array", + back: "`Array.prototype.push` muta el array y devuelve la **nueva longitud**, así que eso es lo que acaba guardado. Lo correcto es `setItems([...items, nuevo])`.", + repetitions: 3, + interval_days: 6, + }, + ], + }, + }, + { + id: "empty", + label: "Nothing due today", + output: "No cards are due right now.", + props: { total_due: 0, cards: [] }, + }, + ], + }, + + // ───────────────────────────────────────────────────────────────── study-plan + { + widget: "study-plan", + tool: "lms_demo_study_plan", + title: "Weekly study plan (lms_get_study_plan)", + variants: [ + { + id: "default", + label: "Mid-week, 3 of 7 goals done", + output: "Study plan for the week of 2026-07-27 — 43% complete.", + props: { + week_start: "2026-07-27", + progress: 43, + goals: [ + { + id: 601, + title: "Terminar «Efectos y limpieza»", + kind: "lesson", + course_id: 101, + required: true, + done: true, + done_at: "2026-07-27T19:12:00Z", + }, + { + id: 602, + title: "Ver «Acciones de formulario y useActionState»", + kind: "lesson", + course_id: 101, + required: true, + done: true, + done_at: "2026-07-28T08:05:00Z", + }, + { + id: 603, + title: "Practicar efectos hasta 70% de dominio", + kind: "practice", + course_id: 101, + required: true, + done: false, + done_at: null, + }, + { + id: 604, + title: "Repasar las 23 tarjetas pendientes", + kind: "review", + course_id: null, + required: false, + done: true, + done_at: "2026-07-28T21:40:00Z", + }, + { + id: 605, + title: "Simulacro del parcial del 4 de agosto", + kind: "exam_prep", + course_id: 101, + required: true, + done: false, + done_at: null, + }, + { + id: 606, + title: "Leer el capítulo 3 de «Tipos condicionales»", + kind: "lesson", + course_id: 102, + required: false, + done: false, + done_at: null, + }, + { + id: 607, + title: "Escribir un resumen de lo aprendido para el blog del grupo", + kind: "custom", + course_id: null, + required: false, + done: false, + done_at: null, + }, + ], + context: { + next_lessons: [ + { + course_id: 101, + course_title: "React 19 en la práctica", + lesson_id: 5018, + lesson_title: "Suspense en listas largas", + }, + { + course_id: 102, + course_title: "TypeScript de cero a estricto", + lesson_id: 6013, + lesson_title: "Tipos condicionales", + }, + ], + due_reviews: 23, + }, + }, + }, + { + id: "done", + label: "Week finished — 100%", + output: "Study plan for the week of 2026-07-20 — complete.", + props: { + week_start: "2026-07-20", + progress: 100, + goals: [ + { id: 501, title: "Terminar el módulo de genéricos", kind: "lesson", course_id: 102, required: true, done: true, done_at: "2026-07-21T18:00:00Z" }, + { id: 502, title: "Practicar inferencia", kind: "practice", course_id: 102, required: true, done: true, done_at: "2026-07-23T20:15:00Z" }, + { id: 503, title: "Repasar tarjetas", kind: "review", course_id: null, required: false, done: true, done_at: "2026-07-25T09:00:00Z" }, + ], + context: { next_lessons: [], due_reviews: 0 }, + }, + }, + { + id: "empty", + label: "No plan set for this week", + output: "No study plan for this week yet.", + props: { + week_start: "2026-07-27", + progress: 0, + goals: [], + context: { + next_lessons: [ + { + course_id: 101, + course_title: "React 19 en la práctica", + lesson_id: 5018, + lesson_title: "Suspense en listas largas", + }, + ], + due_reviews: 23, + }, + }, + }, + ], + }, + + // ────────────────────────────────────────────────────────────── school-overview + { + widget: "school-overview", + tool: "lms_demo_school_overview", + title: "Admin school KPIs (lms_get_school_stats)", + variants: [ + { + id: "default", + label: "Healthy school, 6 courses", + output: "Code Academy Pro — 6 courses, 1,043 active enrollments.", + props: { + school: { + name: "Code Academy Pro", + courses_total: 6, + courses_published: 3, + courses_draft: 2, + courses_archived: 1, + active_enrollments: 1043, + students: 612, + published_lessons: 88, + completion_rate: 54, + exam_submissions: 2417, + avg_exam_score: 76, + at_risk_students: 87, + }, + courses: [ + { + id: 102, + title: "TypeScript de cero a estricto", + status: "published", + active_enrollments: 412, + published_lessons: 18, + completion_rate: 68, + exam_avg: 81, + submission_count: 1104, + }, + { + id: 101, + title: "React 19 en la práctica", + status: "published", + active_enrollments: 318, + published_lessons: 22, + completion_rate: 47, + exam_avg: 74, + submission_count: 902, + }, + { + id: 103, + title: "Postgres y Row Level Security", + status: "published", + active_enrollments: 97, + published_lessons: 15, + completion_rate: 22, + exam_avg: null, + submission_count: 0, + }, + { + id: 106, + title: "jQuery para aplicaciones modernas", + status: "archived", + active_enrollments: 216, + published_lessons: 31, + completion_rate: 91, + exam_avg: 69, + submission_count: 411, + }, + { + id: 104, + title: "Diseño de APIs REST", + status: "draft", + active_enrollments: 0, + published_lessons: 0, + completion_rate: 0, + exam_avg: null, + submission_count: 0, + }, + { + id: 105, + title: "Fundamentos de accesibilidad web", + status: "draft", + active_enrollments: 0, + published_lessons: 2, + completion_rate: 0, + exam_avg: null, + submission_count: 0, + }, + ], + }, + }, + { + id: "new-school", + label: "Freshly created school, no data", + output: "Escuela Nueva — no activity yet.", + props: { + school: { + name: "Escuela Nueva", + courses_total: 1, + courses_published: 0, + courses_draft: 1, + courses_archived: 0, + active_enrollments: 0, + students: 0, + published_lessons: 0, + completion_rate: 0, + exam_submissions: 0, + avg_exam_score: null, + at_risk_students: 0, + }, + courses: [ + { + id: 900, + title: "Mi primer curso", + status: "draft", + active_enrollments: 0, + published_lessons: 0, + completion_rate: 0, + exam_avg: null, + submission_count: 0, + }, + ], + }, + }, + ], + }, + + // ────────────────────────────────────────────────────── student-progress-roster + { + widget: "student-progress-roster", + tool: "lms_demo_student_progress_roster", + title: "Per-course student roster (lms_get_student_progress)", + variants: [ + { + id: "default", + label: "10 students, 3 at risk", + output: "Roster for React 19 en la práctica — 10 students, 3 at risk.", + props: { + course: { id: 101, title: "React 19 en la práctica", published_lessons: 22 }, + summary: { total: 10, at_risk: 3, avg_progress: 52 }, + students: [ + { student_id: "u-001", student_name: "Alicia Nguyen", status: "active", enrolled: "2026-03-04T09:00:00Z", completed_lessons: 22, progress_pct: 100, exam_avg: 92, exam_count: 3, last_active: "2026-07-26T20:11:00Z", at_risk: false }, + { student_id: "u-002", student_name: "Bruno Salas", status: "active", enrolled: "2026-03-04T09:02:00Z", completed_lessons: 19, progress_pct: 86, exam_avg: 78, exam_count: 3, last_active: "2026-07-25T18:44:00Z", at_risk: false }, + { student_id: "u-003", student_name: "Camila Rojas", status: "active", enrolled: "2026-03-11T14:20:00Z", completed_lessons: 14, progress_pct: 64, exam_avg: 64, exam_count: 2, last_active: "2026-07-24T14:19:00Z", at_risk: false }, + { student_id: "u-004", student_name: "Diego Fernández", status: "active", enrolled: "2026-04-01T08:00:00Z", completed_lessons: 3, progress_pct: 14, exam_avg: null, exam_count: 0, last_active: "2026-05-30T11:02:00Z", at_risk: true }, + { student_id: "u-005", student_name: "Emma Whitfield", status: "active", enrolled: "2026-04-15T16:30:00Z", completed_lessons: 21, progress_pct: 95, exam_avg: 100, exam_count: 3, last_active: "2026-07-27T07:55:00Z", at_risk: false }, + { student_id: "u-006", student_name: "Farid Haddad", status: "active", enrolled: "2026-04-22T10:10:00Z", completed_lessons: 6, progress_pct: 27, exam_avg: 41, exam_count: 1, last_active: "2026-07-04T09:31:00Z", at_risk: true }, + { student_id: "u-007", student_name: "Grace Okoye", status: "active", enrolled: "2026-05-02T12:00:00Z", completed_lessons: 11, progress_pct: 50, exam_avg: null, exam_count: 1, last_active: "2026-07-26T08:12:00Z", at_risk: false }, + // No display name on the profile row. + { student_id: "u-008", student_name: null, status: "active", enrolled: "2026-05-19T09:45:00Z", completed_lessons: 0, progress_pct: 0, exam_avg: null, exam_count: 0, last_active: null, at_risk: true }, + { student_id: "u-009", student_name: "Hugo Marín", status: "active", enrolled: "2026-06-02T13:15:00Z", completed_lessons: 13, progress_pct: 59, exam_avg: 87, exam_count: 2, last_active: "2026-07-26T11:55:00Z", at_risk: false }, + { student_id: "u-010", student_name: "Irene Castaño", status: "completed", enrolled: "2026-02-20T08:30:00Z", completed_lessons: 22, progress_pct: 100, exam_avg: 89, exam_count: 3, last_active: "2026-07-12T15:20:00Z", at_risk: false }, + ], + }, + }, + { + id: "empty", + label: "Nobody enrolled yet", + output: "No students enrolled in Diseño de APIs REST.", + props: { + course: { id: 104, title: "Diseño de APIs REST", published_lessons: 0 }, + summary: { total: 0, at_risk: 0, avg_progress: 0 }, + students: [], + }, + }, + ], + }, + + // ─────────────────────────────────────────────────────────── artifact-sandbox + { + widget: "artifact-sandbox", + tool: "lms_demo_artifact_sandbox", + title: "Artifact exercise sandbox (lms_preview_artifact)", + variants: [ + { + id: "default", + label: "Interactive ordering exercise", + output: "Artifact preview for exercise 3301.", + props: { + exercise: { + id: 3301, + title: "Ordena el ciclo de vida de una petición", + instructions: + "Haz clic en los cuatro pasos en el orden en que ocurren cuando un componente monta y pide datos. Si te equivocas, la secuencia se reinicia.", + difficulty: "intermediate", + }, + artifact: { + type: "html", + html: ARTIFACT_HTML, + evaluation_criteria: + "Correcto si el estudiante completa la secuencia montar → fetch → renderizar → limpiar sin reiniciar más de una vez. Se penaliza reiniciar tres o más veces (indica ensayo y error, no comprensión).", + system_prompt: + "Eres el evaluador de este artefacto. Recibes el registro de clics. Devuelve una puntuación 0-100 y una frase de retroalimentación en español, sin revelar el orden correcto si la puntuación es menor que la de aprobado.", + passing_score: 70, + }, + }, + }, + ], + }, + + // ───────────────────────────────────────────────────── landing-page-preview + { + widget: "landing-page-preview", + tool: "lms_demo_landing_page_preview", + title: "Landing page wireframe (lms_get_landing_page)", + variants: [ + { + id: "published", + label: "Live homepage, 8 sections", + output: "«Aprende a programar de verdad» (/home) — PUBLISHED, 8 sections.", + props: { + title: "Aprende a programar de verdad", + slug: "home", + is_published: true, + public_path: "/", + preview_path: "/dashboard/admin/landing-page/preview/pg_7fa21c", + preview_url: "https://code-academy.lmsplatform.com/dashboard/admin/landing-page/preview/pg_7fa21c", + brand_color: "#7c3aed", + warnings: [], + sections: [ + { + type: "Hero", + layout: "hero", + heading: "Aprende a programar de verdad", + subtitle: "Cursos guiados por profesionales en activo, con proyectos que puedes enseñar en una entrevista.", + ctas: ["Empezar gratis", "Ver el temario"], + items: [], + itemCount: 0, + color: null, + }, + { + type: "LogoCloud", + layout: "band", + heading: "Nuestros alumnos trabajan en", + subtitle: "", + ctas: [], + items: ["Mercado Libre", "Globant", "Rappi", "Nubank", "Platzi"], + itemCount: 5, + color: null, + }, + { + type: "Stats", + layout: "stats", + heading: "Los números", + subtitle: "Actualizados cada trimestre", + ctas: [], + items: ["612 alumnos activos", "88 lecciones", "76% nota media", "4.8/5 satisfacción"], + itemCount: 4, + color: "#0f172a", + }, + { + type: "FeatureGrid", + layout: "grid", + heading: "Por qué esta escuela y no otra", + subtitle: "Tres cosas que hacemos distinto", + ctas: [], + items: ["Proyectos reales", "Revisión de código humana", "Bolsa de empleo"], + itemCount: 3, + color: null, + }, + { + type: "CourseList", + layout: "list", + heading: "Cursos disponibles", + subtitle: "", + ctas: ["Ver todos"], + items: [ + "React 19 en la práctica", + "TypeScript de cero a estricto", + "Postgres y Row Level Security", + ], + itemCount: 3, + color: null, + }, + { + type: "Testimonials", + layout: "media", + heading: "Lo que dicen quienes ya pasaron", + subtitle: "", + ctas: [], + items: ["Alicia N. — «Conseguí trabajo en 4 meses»", "Bruno S. — «El feedback humano cambia todo»"], + itemCount: 2, + color: null, + }, + { + type: "FAQ", + layout: "list", + heading: "Preguntas frecuentes", + subtitle: "", + ctas: [], + items: [ + "¿Necesito experiencia previa?", + "¿Los cursos son en vivo?", + "¿Hay certificado?", + "¿Puedo pagar en cuotas?", + ], + itemCount: 4, + color: null, + }, + { + type: "CTA", + layout: "band", + heading: "Empieza hoy, cancela cuando quieras", + subtitle: "Primera semana gratis, sin tarjeta.", + ctas: ["Crear mi cuenta"], + items: [], + itemCount: 0, + color: "#7c3aed", + }, + ], + }, + }, + { + id: "draft-warnings", + label: "Draft with generator warnings", + output: "«Bootcamp de verano» (/bootcamp-verano) — draft, 3 sections, 2 warnings.", + props: { + title: "Bootcamp de verano", + slug: "bootcamp-verano", + is_published: false, + public_path: "/p/bootcamp-verano", + preview_path: "/dashboard/admin/landing-page/preview/pg_c40b18", + preview_url: null, + brand_color: null, + warnings: [ + "El bloque «Pricing» referencia el plan «summer-2026», que no existe en este tenant. Se renderizará vacío.", + "«Hero» no define imagen de fondo; se usará el color de marca.", + ], + sections: [ + { + type: "Hero", + layout: "hero", + heading: "Bootcamp intensivo de verano", + subtitle: "Seis semanas, un proyecto, plazas limitadas.", + ctas: ["Reservar plaza"], + items: [], + itemCount: 0, + color: null, + }, + { + type: "Pricing", + layout: "grid", + heading: "Planes", + subtitle: "", + ctas: [], + items: [], + itemCount: 0, + color: null, + }, + { + type: "RichText", + layout: "text", + heading: "Condiciones", + subtitle: "Letra pequeña que nadie lee pero que tiene que estar", + ctas: [], + items: [], + itemCount: 0, + color: null, + }, + ], + }, + }, + ], + }, +]; + +/** Look a demo up by tool name. */ +export function findDemoByTool(tool: string): WidgetDemo | undefined { + return WIDGET_DEMOS.find((d) => d.tool === tool); +} diff --git a/mcp-server/src/env.ts b/mcp-server/src/env.ts index 64668767..c90da61d 100644 --- a/mcp-server/src/env.ts +++ b/mcp-server/src/env.ts @@ -45,3 +45,17 @@ export function getServiceRoleKey(): string | undefined { export function shouldVerifyJwt(): boolean { return process.env.NODE_ENV === "production"; } + +/** + * Whether the dev-only `lms_demo_*` widget preview tools are registered. + * + * These render every MCP App widget from hand-written fixtures (`demo-data.ts`) + * so the inspector can show a widget with no database and no login. They expose + * no user data, but they are unauthenticated by design, so they are hard-gated: + * an explicit opt-in flag AND a non-production NODE_ENV. + */ +export function demoWidgetsEnabled(): boolean { + return ( + process.env.MCP_DEMO_WIDGETS === "1" && process.env.NODE_ENV !== "production" + ); +} diff --git a/mcp-server/src/register.ts b/mcp-server/src/register.ts index ba0557e2..df589c53 100644 --- a/mcp-server/src/register.ts +++ b/mcp-server/src/register.ts @@ -2,6 +2,35 @@ import type { MCPServer } from "mcp-use/server"; import { recordToolAudit } from "./audit.js"; import { isToolAllowedForRole, roleOf } from "./tool-policy.js"; import { errorResult } from "./format.js"; +import { BRANDING_META_KEY, getTenantBranding } from "./branding.js"; +import { LmsSession } from "./session.js"; + +/** + * Attach the caller's school branding to a widget result. + * + * Widget payloads carry `structuredContent`; plain text/JSON results do not, so + * that is the test for "this renders a widget". The branding rides in `_meta` + * rather than in props: it stays out of the model's context, and no widget's + * `propsSchema` has to grow a field for it. + * + * Everything here is best-effort. An unauthenticated caller, a tenant with no + * colours, or a failed lookup all return the result untouched, and the widget + * falls back to the platform palette. + */ +export async function brandWidgetResult(result: unknown, ctx: unknown): Promise { + if (!result || typeof result !== "object") return result; + const r = result as { structuredContent?: unknown; _meta?: Record }; + if (!r.structuredContent) return result; + + try { + const branding = await getTenantBranding(LmsSession.fromContext(ctx as never)); + if (!branding) return result; + r._meta = { ...(r._meta ?? {}), [BRANDING_META_KEY]: branding }; + } catch { + // No session (or the lookup failed) — leave the widget on the default theme. + } + return result; +} /** * Per-tool guard wrapper. @@ -63,7 +92,7 @@ export function installToolGuards(server: MCPServer): void { if (result && typeof result === "object" && (result as { isError?: boolean }).isError === true) { success = false; } - return result; + return await brandWidgetResult(result, ctx); } catch (err) { success = false; errorMessage = err instanceof Error ? err.message : String(err); diff --git a/mcp-server/src/tool-policy.ts b/mcp-server/src/tool-policy.ts index a0ba1072..3e541fbd 100644 --- a/mcp-server/src/tool-policy.ts +++ b/mcp-server/src/tool-policy.ts @@ -1,4 +1,5 @@ import type { MCPServer, MiddlewareContext, McpMiddlewareFn } from "mcp-use/server"; +import { demoWidgetsEnabled } from "./env.js"; /** * Role-based tool access (Option A). @@ -94,6 +95,10 @@ export function isToolAllowedForRole( role: string | undefined, toolName: string ): boolean { + // Dev widget previews (`MCP_DEMO_WIDGETS=1`) serve static fixtures and are + // meant to be reachable from an inspector session with no LMS login, so they + // are role-less. They do not exist at all unless the flag is set. + if (toolName.startsWith("lms_demo_") && demoWidgetsEnabled()) return true; if (role === "admin") return true; if (role === "student") return STUDENT_TOOLS.has(toolName); if (role !== "teacher") return false; diff --git a/mcp-server/src/tools/demo.ts b/mcp-server/src/tools/demo.ts new file mode 100644 index 00000000..4921c22e --- /dev/null +++ b/mcp-server/src/tools/demo.ts @@ -0,0 +1,112 @@ +import { z } from "zod"; +import type { MCPServer } from "mcp-use/server"; +import { widget, text } from "mcp-use/server"; +import { WIDGET_DEMOS } from "../demo-data.js"; +import { BRANDING_META_KEY, type TenantBranding } from "../branding.js"; + +/** + * Fake schools to preview tenant theming with. + * + * The real branding is injected centrally in `installToolGuards`, which these + * tools deliberately sit in front of (they have no session to look a tenant up + * with), so they carry their own. Same `_meta` key, same shape — this is the + * only way to see a branded widget without a seeded tenant. + */ +const DEMO_BRANDS: Record = { + none: null, + ocean: { + name: "Escuela Marea", + logo_url: null, + primary_color: "#0369a1", + secondary_color: "#0891b2", + }, + sunset: { + name: "Academia Ocaso", + logo_url: null, + primary_color: "#e11d48", + secondary_color: "#f97316", + }, + forest: { + name: "Instituto Verde", + logo_url: null, + primary_color: "#15803d", + secondary_color: "#4d7c0f", + }, +}; + +/** + * Dev-only widget preview tools. + * + * One `lms_demo_` tool per entry in `WIDGET_DEMOS`, each rendering that + * widget with hand-written fixture props (see `src/demo-data.ts`). They exist so + * the MCP inspector can show any widget — including its empty/edge states — + * without a seeded database, without OAuth, and without hunting for a row that + * happens to hit the branch you're working on. + * + * SAFETY + * - Registered ONLY when `MCP_DEMO_WIDGETS=1` and `NODE_ENV !== "production"` + * (see `demoWidgetsEnabled()` in `src/env.ts`). + * - Register these BEFORE `installToolGuards()`: the guards gate every tool on + * the caller's tenant role, and an inspector session with no LMS login has + * no role, so a guarded demo tool would always be rejected. Unwrapped also + * means these calls never touch `mcp_audit_log`. + * - Handlers touch no session and no Supabase client. They are pure data. + */ +export function registerDemoTools(server: MCPServer): void { + for (const demo of WIDGET_DEMOS) { + const variantIds = demo.variants.map((v) => v.id) as [string, ...string[]]; + const variantList = demo.variants + .map((v) => `${v.id} (${v.label})`) + .join(", "); + + server.tool( + { + name: demo.tool, + description: `DEMO DATA — render the '${demo.widget}' widget with fixtures instead of live data. ${demo.title}. Variants: ${variantList}.`, + schema: z.object({ + variant: z + .enum(variantIds) + .optional() + .describe(`Which fixture to render. Defaults to '${demo.variants[0].id}'.`), + brand: z + .enum(["none", "ocean", "sunset", "forest"]) + .optional() + .describe( + "Preview the widget with a fake school's brand colour. 'none' (default) uses the platform palette." + ), + }), + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + widget: { + name: demo.widget, + invoking: `Rendering ${demo.widget} demo…`, + invoked: `${demo.widget} demo ready`, + }, + }, + async (input: { variant?: string; brand?: string }) => { + const chosen = + demo.variants.find((v) => v.id === input.variant) ?? demo.variants[0]; + const branding = DEMO_BRANDS[input.brand ?? "none"] ?? null; + + const result = widget({ + props: chosen.props, + output: text( + `[DEMO FIXTURE — not real data] ${demo.widget} · ${chosen.id}: ${chosen.label}${ + branding ? ` · brand: ${branding.name} (${branding.primary_color})` : "" + }\n\n${chosen.output}` + ), + }); + + return result; + } + ); + } + + console.log( + `[demo] Registered ${WIDGET_DEMOS.length} widget preview tools (MCP_DEMO_WIDGETS=1). Do not enable in production.` + ); +} From 947aea3a85768c85594f37043a212db16d8458d2 Mon Sep 17 00:00:00 2001 From: Guillermo Marin <52298929+guillermoscript@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:07:09 +0200 Subject: [PATCH 2/2] test(mcp): retarget the step-number selector at the brand ramp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `numbersIn` located step markers by grepping the rendered markup for `border-violet-600`. Widgets now paint accents with the tenant brand ramp, so that class no longer exists and the matcher returned [] — the assertion failed even though step numbering (the #561 regression this guards) is unchanged. Anchor on `border-[var(--brand-600)]` instead, which follows the accent rather than a fixed hue. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017sLtN12VfnLziCpst3Pfo2 --- tests/unit/mcp-lesson-mdx.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/unit/mcp-lesson-mdx.test.ts b/tests/unit/mcp-lesson-mdx.test.ts index 2d33a44e..89f5b34d 100644 --- a/tests/unit/mcp-lesson-mdx.test.ts +++ b/tests/unit/mcp-lesson-mdx.test.ts @@ -134,8 +134,11 @@ describe('LessonBody', () => { ' c', '', ].join('\n') + // The step marker is the only element carrying the brand border colour. + // It was `border-violet-600` until widgets moved to the tenant brand ramp + // (`--brand-*`), so this selector tracks the accent, not a fixed hue. const numbersIn = (markup: string) => - [...markup.matchAll(/border-violet-600[^>]*>(\d+) m[1]) + [...markup.matchAll(/border-\[var\(--brand-600\)\][^>]*>(\d+) m[1]) // Rendering the same content twice must not drift: a counter incremented // during render double-counts under React's development double-invoke and