diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index b77c57253..34a1a124d 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -160,6 +160,12 @@ "description": "Quickly swipe through backlog issues to triage decisions like assign, needs-info, defer, close, or ignore.", "version": "1.0.2" }, + { + "name": "brainmax-canvas", + "source": "extensions/brainmax-canvas", + "description": "Interactive concept-mastery dashboard for codebase-grounded BrainMax quizzes, scores and competency reports.", + "version": "1.0.0" + }, { "name": "cast-imaging", "source": "plugins/cast-imaging", diff --git a/extensions/brainmax-canvas/.github/plugin/plugin.json b/extensions/brainmax-canvas/.github/plugin/plugin.json new file mode 100644 index 000000000..5189f9889 --- /dev/null +++ b/extensions/brainmax-canvas/.github/plugin/plugin.json @@ -0,0 +1,19 @@ +{ + "name": "brainmax-canvas", + "description": "Interactive concept-mastery dashboard for codebase-grounded BrainMax quizzes, scores and competency reports.", + "version": "1.0.0", + "author": { + "name": "Julia Muiruri", + "url": "https://github.com/juliamuiruri4" + }, + "keywords": [ + "assessment", + "student-developer", + "developer-education", + "quiz", + "conceptual-understanding", + "skills-mastery" + ], + "logo": "assets/preview.png", + "extensions": "." +} diff --git a/extensions/brainmax-canvas/README.md b/extensions/brainmax-canvas/README.md new file mode 100644 index 000000000..bac09ae9e --- /dev/null +++ b/extensions/brainmax-canvas/README.md @@ -0,0 +1,38 @@ +# BrainMax Canvas + +BrainMax Canvas is the interactive dashboard for codebase-grounded concept-mastery quizes. It presents detected knowledge domains, accepts freeform answers, shows rubric scores, and compiles completed domains into a competency report. + +The Canvas is an optional visual layer. Question generation and scoring stay with the [BrainMaxxing Agent Skills](https://gh.io/brainmaxxing/skills), and the skills continue to work in clients without Canvas support. + +![BrainMax domain selection](assets/preview.png) + +## Install + +Install the Brainmax Canvas extension through GitHub Copilot or place this directory at `.github/extensions/brainmax-canvas/` in a project. + +Install the companion Brainmaxxing Agent Skills separately: + +```bash +npx skills add juliamuiruri4/brainmaxxing +``` + +**Reload your extensions and skills.** + +## How to use + +1. In a new agent session, invoke `/brainmax` in a repository. +1. Wait while BrainMax detects the knowledge domains represented in the codebase, and opens the Canvas. +1. Select a domain in the Canvas. +1. Answer each code-grounded question in the Canvas. Answers are relayed to the active session for scoring. +1. Pick another domain or compile a cross-domain report. + +## Architecture + +- `extension.mjs` declares the Canvas and validates state transitions. +- `lib/state.mjs` owns per-instance quiz state and score-tier helpers. +- `lib/http-server.mjs` serves the UI and streams state over server-sent events. +- `public/` contains the dependency-free browser interface. + +## License + +MIT diff --git a/extensions/brainmax-canvas/assets/preview.png b/extensions/brainmax-canvas/assets/preview.png new file mode 100644 index 000000000..682f6bd79 Binary files /dev/null and b/extensions/brainmax-canvas/assets/preview.png differ diff --git a/extensions/brainmax-canvas/extension.mjs b/extensions/brainmax-canvas/extension.mjs new file mode 100644 index 000000000..19b13b68f --- /dev/null +++ b/extensions/brainmax-canvas/extension.mjs @@ -0,0 +1,536 @@ +// Extension: brainmax-canvas +// +// Canvas UI for the BrainMax quiz orchestrator (https://gh.io/brainmaxxing/skills). +// The canvas displays quiz state and accepts freeform answers, but it never +// scores answers itself. The agent drives it through the actions declared +// below; canvas answers are relayed into normal chat, where the domain skill +// scores them and reports back via `record_score` / `complete_domain` / +// `show_report`. +// +// See README.md in this directory for the full action contract with +// example payloads. + +import { joinSession, createCanvas } from "@github/copilot-sdk/extension"; +import { randomUUID } from "node:crypto"; +import { startInstanceServer } from "./lib/http-server.mjs"; +import { getState, deleteState, tierForScore, tierForPercentage, TIER_LABELS } from "./lib/state.mjs"; + +/** @type {Map>>} */ +const servers = new Map(); +/** @type {Map} */ +const stateCleanupTimers = new Map(); +const STATE_RETENTION_MS = 30 * 60 * 1000; +const QUESTION_TYPES = new Set(["Explain", "Predict", "Refactor", "Debug"]); + +function isNonEmptyString(value) { + return typeof value === "string" && value.trim().length > 0; +} + +function isValidQuestion(question, total, expectedIndex) { + return Boolean( + question + && Number.isInteger(question.index) + && question.index === expectedIndex + && question.total === total + && isNonEmptyString(question.prompt) + && QUESTION_TYPES.has(question.type), + ); +} + +async function ensureServer(instanceId, session) { + let entry = servers.get(instanceId); + if (entry) return entry; + + const cleanupTimer = stateCleanupTimers.get(instanceId); + if (cleanupTimer) { + clearTimeout(cleanupTimer); + stateCleanupTimers.delete(instanceId); + } + + entry = await startInstanceServer( + instanceId, + () => getState(instanceId), + (event) => handleClientEvent(instanceId, session, event), + ); + servers.set(instanceId, entry); + return entry; +} + +/** Handle an interaction posted from the browser back into chat. */ +function handleClientEvent(instanceId, session, event) { + switch (event.type) { + case "select-domain": { + const state = getState(instanceId); + if (state.view !== "domains") { + return { ok: false, error: "Return to domain selection before starting another quiz." }; + } + const domain = state.domains.find((candidate) => candidate.id === event.domainId); + if (!domain) return { ok: false, error: "That knowledge area is not available." }; + if (state.domainSelectionStatus === "submitting") { + return { ok: false, error: "A quiz is already being started." }; + } + + const prompt = `Start the ${domain.name} quiz.`; + state.domainSelectionStatus = "submitting"; + state.domainSelectionError = null; + state.announcement = `Starting ${domain.name} quiz.`; + servers.get(instanceId)?.broadcastState(); + // Avoid calling session.send synchronously from an event-loop tick + // that originated outside the normal turn flow. + setTimeout(async () => { + try { + await session.send({ prompt }); + } catch (err) { + const currentState = getState(instanceId); + if (currentState.domainSelectionStatus !== "submitting") return; + currentState.domainSelectionStatus = "error"; + currentState.domainSelectionError = "The quiz could not be started. Try selecting the domain again."; + currentState.announcement = currentState.domainSelectionError; + servers.get(instanceId)?.broadcastState(); + console.error("Failed to start BrainMax quiz", err); + } + }, 0); + return { ok: true }; + } + case "submit-answer": { + const state = getState(instanceId); + const answer = typeof event.answer === "string" ? event.answer.trim() : ""; + if (state.view !== "quiz" || !state.question || event.questionId !== state.question.id) { + return { ok: false, error: "This question is no longer active. Review the current question and try again." }; + } + if (!answer) { + return { ok: false, error: "Enter an answer before submitting." }; + } + if (answer.length > 8000) { + return { ok: false, error: "Keep your answer under 8,000 characters." }; + } + if (state.answerStatus === "submitting" || state.answerStatus === "scored") { + return { ok: false, error: "An answer has already been submitted for this question." }; + } + + const question = state.question; + const domainName = state.quiz?.domainName || "BrainMax"; + state.answerStatus = "submitting"; + state.answerError = null; + state.lastScore = null; + state.announcement = `Answer submitted for question ${question.index}.`; + servers.get(instanceId)?.broadcastState(); + + setTimeout(async () => { + try { + await session.send({ + prompt: `Answer to ${domainName} question ${question.index} of ${question.total}:\n\n${answer}`, + }); + } catch (err) { + const currentState = getState(instanceId); + if (currentState.question?.id !== question.id || currentState.answerStatus !== "submitting") return; + currentState.answerStatus = "error"; + currentState.answerError = "Your answer could not be sent to Copilot. Try again."; + currentState.announcement = currentState.answerError; + servers.get(instanceId)?.broadcastState(); + console.error("Failed to submit BrainMax answer", err); + } + }, 0); + return { ok: true }; + } + case "choose-another-domain": { + const state = getState(instanceId); + if (state.view !== "summary" && state.view !== "report") { + return { ok: false, error: "Finish the active quiz before choosing another domain." }; + } + state.view = "domains"; + state.question = null; + state.lastScore = null; + state.quiz = null; + state.domainSelectionStatus = "idle"; + state.domainSelectionError = null; + state.reportRequestStatus = "idle"; + state.reportRequestError = null; + state.announcement = "Back to domain selection. Choose a knowledge area to start another quiz."; + servers.get(instanceId)?.broadcastState(); + return { ok: true }; + } + case "compile-report": { + const state = getState(instanceId); + if (state.view !== "summary" || state.completed.length === 0) { + return { ok: false, error: "Complete a domain before compiling the report." }; + } + if (state.reportRequestStatus === "submitting") { + return { ok: false, error: "The report is already being compiled." }; + } + state.reportRequestStatus = "submitting"; + state.reportRequestError = null; + state.announcement = "Compiling competency report."; + servers.get(instanceId)?.broadcastState(); + setTimeout(async () => { + try { + await session.send({ prompt: "Compile report" }); + } catch (err) { + const currentState = getState(instanceId); + if (currentState.reportRequestStatus !== "submitting") return; + currentState.reportRequestStatus = "error"; + currentState.reportRequestError = "The report could not be requested. Try again."; + currentState.announcement = currentState.reportRequestError; + servers.get(instanceId)?.broadcastState(); + console.error("Failed to compile BrainMax report", err); + } + }, 0); + return { ok: true }; + } + default: + return { ok: false, error: "Unknown canvas event." }; + } +} + +const session = await joinSession({ + canvases: [ + createCanvas({ + id: "brainmax-canvas", + displayName: "BrainMax", + description: + "Interactive dashboard for the BrainMax concept-mastery quiz: shows detected domains, accepts freeform answers, tracks live quiz progress and running score, and displays the final competency report. Opening this Canvas does not populate it. After opening, invoke set_domains before responding in chat; then drive it via start_quiz / set_question / record_score / complete_domain / show_report.", + actions: [ + { + name: "set_domains", + description: + "MANDATORY immediately after opening BrainMax: render the detected-domain selection screen before sending the domain list in chat. Opening the Canvas alone does not populate it. Only include domains that were actually detected.", + inputSchema: { + type: "object", + required: ["domains"], + properties: { + domains: { + type: "array", + items: { + type: "object", + required: ["id", "name"], + properties: { + id: { type: "string", description: "Skill slug, e.g. 'api-design'" }, + name: { type: "string", description: "Display name, e.g. 'API Design'" }, + }, + }, + }, + }, + }, + handler: async (ctx) => { + const state = getState(ctx.instanceId); + const domains = ctx.input?.domains; + if (!Array.isArray(domains) || domains.some((domain) => !isNonEmptyString(domain?.id) || !isNonEmptyString(domain?.name))) { + return { ok: false, error: "Each detected domain requires a non-empty id and name." }; + } + const normalizedDomains = domains.map((domain) => ({ id: domain.id.trim(), name: domain.name.trim() })); + if (new Set(normalizedDomains.map((domain) => domain.id)).size !== normalizedDomains.length) { + return { ok: false, error: "Detected domain ids must be unique." }; + } + state.domains = normalizedDomains; + state.view = "domains"; + state.domainSelectionStatus = "idle"; + state.domainSelectionError = null; + state.reportRequestStatus = "idle"; + state.reportRequestError = null; + state.announcement = `${state.domains.length} knowledge areas detected.`; + servers.get(ctx.instanceId)?.broadcastState(); + return { ok: true }; + }, + }, + { + name: "show_domains", + description: + "Return to the domain selection screen using the domains already sent via set_domains (e.g. after a student finishes one domain and wants to pick another).", + handler: async (ctx) => { + const state = getState(ctx.instanceId); + state.view = "domains"; + state.question = null; + state.lastScore = null; + state.quiz = null; + state.domainSelectionStatus = "idle"; + state.domainSelectionError = null; + state.reportRequestStatus = "idle"; + state.reportRequestError = null; + servers.get(ctx.instanceId)?.broadcastState(); + return { ok: true }; + }, + }, + { + name: "start_quiz", + description: "MANDATORY before presenting question 1 in chat: atomically start the selected quiz and display the exact first question. Wait for success before sending the same question in chat.", + inputSchema: { + type: "object", + required: ["domainId", "domainName", "total", "firstQuestion"], + properties: { + domainId: { type: "string" }, + domainName: { type: "string" }, + total: { type: "number", description: "Total questions in this quiz, typically 5." }, + firstQuestion: { + type: "object", + required: ["index", "total", "prompt", "type"], + properties: { + index: { type: "number", description: "Must be 1." }, + total: { type: "number", description: "Must match the quiz total." }, + prompt: { type: "string", minLength: 1 }, + type: { type: "string", enum: ["Explain", "Predict", "Refactor", "Debug"] }, + }, + }, + }, + }, + handler: async (ctx) => { + const state = getState(ctx.instanceId); + const { domainId, domainName, total, firstQuestion } = ctx.input || {}; + const domain = state.domains.find((candidate) => candidate.id === domainId); + if (!domain || domain.name !== domainName) { + return { ok: false, error: "start_quiz must target a detected domain." }; + } + if (!Number.isInteger(total) || total < 1 || total > 20 || !isValidQuestion(firstQuestion, total, 1)) { + return { ok: false, error: "start_quiz requires a valid Question 1 and a matching total from 1 to 20." }; + } + state.quiz = { + domainId, + domainName, + total: total ?? 5, + index: 1, + runningScore: 0, + runningMax: 0, + history: [], + }; + state.question = { ...firstQuestion, id: randomUUID() }; + state.domainSelectionStatus = "idle"; + state.domainSelectionError = null; + state.answerStatus = "idle"; + state.answerError = null; + state.lastScore = null; + state.summary = null; + state.view = "quiz"; + state.announcement = `Starting ${domainName} quiz.`; + servers.get(ctx.instanceId)?.broadcastState(); + return { ok: true }; + }, + }, + { + name: "set_question", + description: "MANDATORY before presenting each question in chat: show the exact same current question in the Canvas and wait for this action to succeed.", + inputSchema: { + type: "object", + required: ["index", "total", "prompt", "type"], + properties: { + index: { type: "number", description: "1-based question number." }, + total: { type: "number" }, + prompt: { type: "string", description: "The question text, grounded in the student's code." }, + type: { type: "string", enum: ["Explain", "Predict", "Refactor", "Debug"] }, + }, + }, + handler: async (ctx) => { + const state = getState(ctx.instanceId); + const { index, total, prompt, type } = ctx.input || {}; + const expectedIndex = (state.quiz?.history.length ?? 0) + 1; + const question = { index, total, prompt, type }; + if (!state.quiz || state.answerStatus !== "scored" || index !== expectedIndex) { + return { ok: false, error: "Score the active question before advancing to the next one." }; + } + if (index > state.quiz.total || !isValidQuestion(question, state.quiz.total, expectedIndex)) { + return { ok: false, error: "The next question must use the quiz total and next in-range index." }; + } + state.question = { id: randomUUID(), ...question }; + state.answerStatus = "idle"; + state.answerError = null; + if (state.quiz) state.quiz.index = index; + state.view = "quiz"; + state.announcement = `Question ${index} of ${total}: ${type}.`; + servers.get(ctx.instanceId)?.broadcastState(); + return { ok: true }; + }, + }, + { + name: "record_score", + description: + "Reveal the score for the question just answered (0-3 rubric) with a one-sentence explanation. For questions 1-4, immediately follow this successful action with set_question for the next question before responding in chat. For question 5, follow with complete_domain.", + inputSchema: { + type: "object", + required: ["index", "score", "feedback"], + properties: { + index: { type: "number" }, + score: { type: "number", minimum: 0, maximum: 3 }, + feedback: { type: "string", description: "One-sentence explanation of the score." }, + }, + }, + handler: async (ctx) => { + const state = getState(ctx.instanceId); + const { index, score, feedback } = ctx.input || {}; + if (!state.quiz || !state.question || index !== state.question.index || state.answerStatus !== "submitting") { + return { ok: false, error: "record_score must target the active question after its answer is submitted." }; + } + if (!Number.isInteger(score) || score < 0 || score > 3 || !isNonEmptyString(feedback)) { + return { ok: false, error: "record_score requires an integer score from 0 to 3 and non-empty feedback." }; + } + const tier = tierForScore(score); + state.lastScore = { index, score, max: 3, feedback, tier, tierLabel: TIER_LABELS[tier] }; + state.answerStatus = "scored"; + state.answerError = null; + if (state.quiz) { + state.quiz.runningScore += score; + state.quiz.runningMax += 3; + state.quiz.history.push({ index, score, tier }); + } + state.announcement = `Question ${index} scored ${score} of 3: ${TIER_LABELS[tier]}.`; + servers.get(ctx.instanceId)?.broadcastState(); + return { ok: true }; + }, + }, + { + name: "complete_domain", + description: "MANDATORY after Question 5: show the domain summary with non-empty strongestArea and gap analysis before presenting the summary in chat.", + inputSchema: { + type: "object", + required: ["domainId", "domainName", "total", "max", "percentage", "strongestArea", "gap"], + properties: { + domainId: { type: "string" }, + domainName: { type: "string" }, + total: { type: "number", description: "Total points earned." }, + max: { type: "number", description: "Max possible points." }, + percentage: { type: "number" }, + strongestArea: { type: "string", minLength: 1 }, + gap: { type: "string", minLength: 1 }, + }, + }, + handler: async (ctx) => { + const state = getState(ctx.instanceId); + const input = ctx.input || {}; + if ( + !state.quiz + || state.answerStatus !== "scored" + || state.quiz.domainId !== input.domainId + || state.quiz.domainName !== input.domainName + || state.quiz.history.length !== state.quiz.total + ) { + return { ok: false, error: "Complete and score every question before completing the domain." }; + } + if (input.total !== state.quiz.runningScore || input.max !== state.quiz.runningMax) { + return { ok: false, error: "Domain totals must match the recorded Canvas scores." }; + } + if (!isNonEmptyString(input.strongestArea) || !isNonEmptyString(input.gap)) { + return { ok: false, error: "Domain summaries require a strongest area and a gap analysis." }; + } + const percentage = input.max === 0 ? 0 : (input.total / input.max) * 100; + state.summary = { ...input, percentage, tier: tierForPercentage(percentage) }; + const existingIdx = state.completed.findIndex((d) => d.id === input.domainId); + const entry = { + id: input.domainId, + name: input.domainName, + score: input.total, + max: input.max, + percentage, + tier: tierForPercentage(percentage), + strongestArea: input.strongestArea, + gap: input.gap, + }; + if (existingIdx >= 0) state.completed[existingIdx] = entry; + else state.completed.push(entry); + state.view = "summary"; + state.announcement = `${input.domainName} complete: ${percentage}%.`; + servers.get(ctx.instanceId)?.broadcastState(); + return { ok: true }; + }, + }, + { + name: "show_report", + description: "MANDATORY after the student says 'compile report': display the complete competency report before presenting the same report in chat. Recorded Canvas scores are authoritative.", + inputSchema: { + type: "object", + required: ["overallScore", "overallMax", "overallPercentage", "domains", "strongestAreas", "priorityAreas", "recommendations", "nextChallenge"], + properties: { + overallScore: { type: "number" }, + overallMax: { type: "number" }, + overallPercentage: { type: "number" }, + domains: { + type: "array", + items: { + type: "object", + required: ["name", "score", "max", "percentage"], + properties: { + name: { type: "string" }, + score: { type: "number" }, + max: { type: "number" }, + percentage: { type: "number" }, + }, + }, + }, + strongestAreas: { type: "array", items: { type: "string" } }, + priorityAreas: { + type: "array", + items: { + type: "object", + required: ["name", "concepts"], + properties: { + name: { type: "string" }, + concepts: { type: "array", items: { type: "string" } }, + }, + }, + }, + recommendations: { type: "array", items: { type: "string" } }, + nextChallenge: { type: "string", minLength: 1 }, + }, + }, + handler: async (ctx) => { + const state = getState(ctx.instanceId); + if (state.completed.length === 0) { + return { ok: false, error: "Complete at least one domain before compiling a report." }; + } + if (!isNonEmptyString(ctx.input?.nextChallenge)) { + return { ok: false, error: "The report requires a concrete next challenge." }; + } + const domains = state.completed.map(({ name, score, max, percentage }) => ({ name, score, max, percentage })); + const overallScore = domains.reduce((sum, domain) => sum + domain.score, 0); + const overallMax = domains.reduce((sum, domain) => sum + domain.max, 0); + const overallPercentage = overallMax === 0 ? 0 : (overallScore / overallMax) * 100; + // The agent's qualitative lists are authoritative here: the schema + // already requires them and they mirror the chat report. Scores and + // the domain table stay server-derived from recorded Canvas results. + const strongestAreas = Array.isArray(ctx.input?.strongestAreas) + ? ctx.input.strongestAreas.filter(isNonEmptyString) + : []; + const priorityAreas = Array.isArray(ctx.input?.priorityAreas) + ? ctx.input.priorityAreas + .filter((area) => area && isNonEmptyString(area.name)) + .map((area) => ({ + name: area.name, + concepts: Array.isArray(area.concepts) ? area.concepts.filter(isNonEmptyString) : [], + })) + : []; + state.report = { + ...ctx.input, + overallScore, + overallMax, + overallPercentage, + domains, + strongestAreas, + priorityAreas, + }; + state.reportRequestStatus = "idle"; + state.reportRequestError = null; + state.view = "report"; + state.announcement = `Report ready: ${Math.round(overallPercentage)}% overall.`; + servers.get(ctx.instanceId)?.broadcastState(); + return { ok: true }; + }, + }, + ], + open: async (ctx) => { + const entry = await ensureServer(ctx.instanceId, session); + return { title: "BrainMax", url: entry.url }; + }, + onClose: async (ctx) => { + const entry = servers.get(ctx.instanceId); + if (entry) { + servers.delete(ctx.instanceId); + await entry.close(); + } + const existingTimer = stateCleanupTimers.get(ctx.instanceId); + if (existingTimer) clearTimeout(existingTimer); + const cleanupTimer = setTimeout(() => { + deleteState(ctx.instanceId); + stateCleanupTimers.delete(ctx.instanceId); + }, STATE_RETENTION_MS); + cleanupTimer.unref?.(); + stateCleanupTimers.set(ctx.instanceId, cleanupTimer); + }, + }), + ], +}); diff --git a/extensions/brainmax-canvas/lib/http-server.mjs b/extensions/brainmax-canvas/lib/http-server.mjs new file mode 100644 index 000000000..410709ec6 --- /dev/null +++ b/extensions/brainmax-canvas/lib/http-server.mjs @@ -0,0 +1,164 @@ +// Local HTTP server for one BrainMax canvas instance: serves the static +// frontend (public/), streams state updates to it over Server-Sent Events, +// and accepts interactions from the page to relay back into chat +// via session.send(). + +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PUBLIC_DIR = path.join(__dirname, "..", "public"); + +const MIME = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", +}; +const MAX_EVENT_BODY_BYTES = 16 * 1024; +const SSE_HEARTBEAT_MS = 20_000; + +function writeHeaders(res, status, contentType, extra = {}) { + res.writeHead(status, { + "Content-Type": contentType, + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + "Cross-Origin-Resource-Policy": "same-origin", + "Content-Security-Policy": "default-src 'self'; connect-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self'; base-uri 'none'; form-action 'self'", + ...extra, + }); +} + +/** + * @param {string} instanceId + * @param {import("./state.mjs").InstanceState} getState - fn returning current state + * @param {(event: {type: string, [k: string]: unknown}) => void} onClientEvent + */ +export async function startInstanceServer(instanceId, getStateFn, onClientEvent) { + /** @type {Set} */ + const sseClients = new Set(); + + function broadcastState() { + const payload = `data: ${JSON.stringify(getStateFn())}\n\n`; + for (const res of sseClients) { + res.write(payload); + } + } + + const heartbeat = setInterval(() => { + for (const res of sseClients) { + res.write(`: heartbeat ${Date.now()}\n\n`); + } + }, SSE_HEARTBEAT_MS); + heartbeat.unref?.(); + + const server = createServer(async (req, res) => { + try { + const url = new URL(req.url, "http://localhost"); + + if (url.pathname === "/events" && req.method === "GET") { + writeHeaders(res, 200, "text/event-stream", { Connection: "keep-alive" }); + res.write(`data: ${JSON.stringify(getStateFn())}\n\n`); + sseClients.add(res); + req.on("close", () => sseClients.delete(res)); + return; + } + + if (url.pathname === "/state" && req.method === "GET") { + writeHeaders(res, 200, MIME[".json"]); + res.end(JSON.stringify(getStateFn())); + return; + } + + if (url.pathname === "/event" && req.method === "POST") { + if (!String(req.headers["content-type"] || "").toLowerCase().startsWith("application/json")) { + writeHeaders(res, 415, MIME[".json"]); + res.end(JSON.stringify({ ok: false, error: "content type must be application/json" })); + return; + } + let body = ""; + for await (const chunk of req) { + body += chunk; + if (Buffer.byteLength(body, "utf8") > MAX_EVENT_BODY_BYTES) { + writeHeaders(res, 413, MIME[".json"]); + res.end(JSON.stringify({ ok: false, error: "event payload is too large" })); + return; + } + } + let event; + try { + event = JSON.parse(body || "{}"); + } catch { + writeHeaders(res, 400, MIME[".json"]); + res.end(JSON.stringify({ ok: false, error: "invalid JSON" })); + return; + } + if (!event || typeof event !== "object" || Array.isArray(event)) { + writeHeaders(res, 400, MIME[".json"]); + res.end(JSON.stringify({ ok: false, error: "event payload must be an object" })); + return; + } + const result = onClientEvent(event) || { ok: true }; + writeHeaders(res, result.ok === false ? 400 : 202, MIME[".json"]); + res.end(JSON.stringify(result)); + return; + } + + // Static file serving for everything else, defaulting to index.html. + if (req.method !== "GET" && req.method !== "HEAD") { + writeHeaders(res, 405, "text/plain; charset=utf-8", { Allow: "GET, HEAD" }); + res.end("Method not allowed"); + return; + } + let relPath = url.pathname === "/" ? "/index.html" : url.pathname; + const filePath = path.resolve(PUBLIC_DIR, `.${relPath}`); + const relativePath = path.relative(PUBLIC_DIR, filePath); + if (relativePath.startsWith(`..${path.sep}`) || relativePath === ".." || path.isAbsolute(relativePath)) { + writeHeaders(res, 403, "text/plain; charset=utf-8"); + res.end("Forbidden"); + return; + } + const ext = path.extname(filePath); + const data = await readFile(filePath); + writeHeaders(res, 200, MIME[ext] || "application/octet-stream"); + res.end(req.method === "HEAD" ? undefined : data); + } catch (err) { + if (err && err.code === "ENOENT") { + writeHeaders(res, 404, "text/plain; charset=utf-8"); + res.end("Not found"); + return; + } + console.error(`BrainMax canvas server error for ${instanceId}`, err); + writeHeaders(res, 500, "text/plain; charset=utf-8"); + res.end("Internal server error"); + } + }); + + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + } catch (error) { + clearInterval(heartbeat); + throw error; + } + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + + return { + url: `http://127.0.0.1:${port}/`, + broadcastState, + close: () => + new Promise((resolve) => { + clearInterval(heartbeat); + for (const res of sseClients) res.end(); + server.close(() => resolve()); + }), + }; +} diff --git a/extensions/brainmax-canvas/lib/state.mjs b/extensions/brainmax-canvas/lib/state.mjs new file mode 100644 index 000000000..f52b2f5a1 --- /dev/null +++ b/extensions/brainmax-canvas/lib/state.mjs @@ -0,0 +1,76 @@ +// In-memory per-instance state for the BrainMax canvas. +// +// Each open canvas instance (identified by instanceId) gets its own state +// object. Action handlers in extension.mjs mutate this state; the HTTP +// server (lib/http-server.mjs) serializes it to connected SSE clients so the +// browser re-renders without polling. + +/** @type {Map} */ +const instances = new Map(); + +/** + * @typedef {Object} InstanceState + * @property {"domains"|"quiz"|"summary"|"report"} view + * @property {Array<{id: string, name: string}>} domains + * @property {Array<{id: string, name: string, score: number, max: number, percentage: number}>} completed + * @property {null|Object} quiz - { domainId, domainName, total, index, runningScore, runningMax, history: [] } + * @property {null|Object} question - { id, index, total, prompt, type } + * @property {"idle"|"submitting"|"error"} domainSelectionStatus + * @property {null|string} domainSelectionError + * @property {"idle"|"submitting"|"scored"|"error"} answerStatus + * @property {null|string} answerError + * @property {null|Object} lastScore - { index, score, max, feedback, tier } (transient reveal) + * @property {null|Object} summary - payload from complete_domain + * @property {null|Object} report - payload from show_report + * @property {"idle"|"submitting"|"error"} reportRequestStatus + * @property {null|string} reportRequestError + * @property {string} announcement - latest aria-live text + */ + +function freshState() { + return { + view: "domains", + domains: [], + completed: [], + quiz: null, + question: null, + domainSelectionStatus: "idle", + domainSelectionError: null, + answerStatus: "idle", + answerError: null, + lastScore: null, + summary: null, + report: null, + reportRequestStatus: "idle", + reportRequestError: null, + announcement: "", + }; +} + +export function getState(instanceId) { + let state = instances.get(instanceId); + if (!state) { + state = freshState(); + instances.set(instanceId, state); + } + return state; +} + +export function deleteState(instanceId) { + instances.delete(instanceId); +} + +/** Map a 0-100 percentage to a score tier index 0-3 (matches shared/scoring-rubric.md bands). */ +export function tierForPercentage(percentage) { + if (percentage >= 90) return 3; + if (percentage >= 70) return 2; + if (percentage >= 50) return 1; + return 0; +} + +/** Map a raw 0-3 question score directly to its tier (same scale, no banding needed). */ +export function tierForScore(score) { + return Math.max(0, Math.min(3, Math.round(score))); +} + +export const TIER_LABELS = ["No understanding", "Recognition", "Application", "Mastery"]; diff --git a/extensions/brainmax-canvas/package.json b/extensions/brainmax-canvas/package.json new file mode 100644 index 000000000..c94e44df3 --- /dev/null +++ b/extensions/brainmax-canvas/package.json @@ -0,0 +1,15 @@ +{ + "name": "brainmax-canvas", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "extension.mjs", + "description": "Canvas UI for codebase-grounded BrainMax concept-mastery quizzes.", + "scripts": { + "check": "node --check extension.mjs && node --check lib/http-server.mjs && node --check lib/state.mjs && node --check public/app.js", + "test": "node --test" + }, + "engines": { + "node": ">=20" + } +} diff --git a/extensions/brainmax-canvas/public/app.js b/extensions/brainmax-canvas/public/app.js new file mode 100644 index 000000000..4d00d2b17 --- /dev/null +++ b/extensions/brainmax-canvas/public/app.js @@ -0,0 +1,428 @@ +// BrainMax canvas frontend — vanilla JS +// Renders whatever state the extension pushes over SSE; posts interactions +// back to the extension, which relays them into chat via session.send(). + +const DOMAIN_CODES = { + "api-design": "API", + "database-design": "DB", + "system-architecture": "SYS", + "implementation-patterns": "IMPL", + "testing-strategy": "TEST", + "security-fundamentals": "SEC", + "devops-and-ci-cd": "CI/CD", + "error-handling-and-resilience": "ERR", + "requirements-and-scope": "REQ", + "domain-modeling": "DDD", + "ui-and-frontend": "UI", + observability: "OBS", +}; + +function domainCode(id, name) { + if (DOMAIN_CODES[id]) return DOMAIN_CODES[id]; + return (name || id || "??").slice(0, 4).toUpperCase(); +} + +function tierClass(tier) { + return `tier-${Math.max(0, Math.min(3, tier ?? 0))}`; +} + +// Mirrors lib/state.mjs#tierForPercentage. Kept in sync deliberately: the +// frontend recomputes from `percentage` instead of trusting a passed-through +// `tier` field, so a payload that omits it still renders the correct color. +function tierForPercentage(percentage) { + if (percentage >= 90) return 3; + if (percentage >= 70) return 2; + if (percentage >= 50) return 1; + return 0; +} + +const TIER_LABELS = ["No understanding", "Recognition", "Application", "Mastery"]; + +async function postEvent(event) { + const response = await fetch("/event", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(event), + }); + const result = await response.json(); + if (!response.ok || result.ok === false) { + throw new Error(result.error || "The canvas event could not be sent."); + } + return result; +} + +function sendEvent(event) { + postEvent(event).catch((err) => console.error("Failed to post event", err)); +} + +const views = { + domains: document.getElementById("view-domains"), + quiz: document.getElementById("view-quiz"), + summary: document.getElementById("view-summary"), + report: document.getElementById("view-report"), +}; + +let lastAnnouncement = ""; +const liveRegion = document.getElementById("live-region"); +const answerForm = document.getElementById("answer-form"); +const answerInput = document.getElementById("answer-input"); +const answerStatus = document.getElementById("answer-status"); +const submitAnswerButton = document.getElementById("btn-submit-answer"); +let latestState = null; +let activeQuestionId = null; +let locallySubmitting = false; +let localAnswerError = ""; + +function announce(text) { + if (!text || text === lastAnnouncement) return; + lastAnnouncement = text; + liveRegion.textContent = text; +} + +function showView(name) { + for (const [key, el] of Object.entries(views)) { + el.hidden = key !== name; + } +} + +function updateAnswerControls() { + const question = latestState?.question; + const serverStatus = localAnswerError ? "error" : latestState?.answerStatus || "idle"; + if (serverStatus === "submitting" || serverStatus === "scored" || serverStatus === "error") { + locallySubmitting = false; + } + const isSubmitting = locallySubmitting || serverStatus === "submitting"; + const isScored = serverStatus === "scored"; + const canEdit = Boolean(question?.id) && !isSubmitting && !isScored; + + answerForm.hidden = !question; + answerInput.disabled = !canEdit; + submitAnswerButton.disabled = !canEdit || !answerInput.value.trim(); + answerStatus.dataset.state = serverStatus; + + if (isSubmitting) { + answerStatus.textContent = "Evaluating your answer…"; + } else if (serverStatus === "error") { + answerStatus.textContent = localAnswerError || latestState?.answerError || "Your answer could not be submitted. Try again."; + } else if (isScored) { + answerStatus.textContent = "Answer scored."; + } else { + answerStatus.textContent = ""; + } +} + +function updateScoreReveal() { + const revealEl = document.getElementById("score-reveal"); + const isSubmitting = locallySubmitting || latestState?.answerStatus === "submitting"; + const score = latestState?.lastScore; + if (!score || isSubmitting) { + revealEl.hidden = true; + return; + } + + revealEl.hidden = false; + const chip = document.getElementById("score-reveal-chip"); + chip.textContent = `${score.score} / 3`; + chip.className = `score-chip mono ${tierClass(score.tier)}`; + document.getElementById("score-reveal-feedback").textContent = + `Question ${score.index}: ${score.tierLabel} — ${score.feedback}`; +} + +function renderDomains(state) { + const grid = document.getElementById("domain-grid"); + const selectionStatus = document.getElementById("domain-selection-status"); + const isStarting = state.domainSelectionStatus === "submitting"; + grid.innerHTML = ""; + selectionStatus.dataset.state = state.domainSelectionStatus; + selectionStatus.textContent = isStarting + ? "Starting quiz…" + : state.domainSelectionStatus === "error" + ? state.domainSelectionError || "The quiz could not be started. Try again." + : ""; + for (const domain of state.domains) { + const completed = state.completed.find((d) => d.id === domain.id); + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "domain-tile"; + btn.disabled = isStarting; + btn.setAttribute("aria-label", `Start the ${domain.name} quiz`); + + const code = document.createElement("span"); + code.className = "domain-code"; + code.textContent = domainCode(domain.id, domain.name); + + const name = document.createElement("span"); + name.className = "domain-name"; + name.textContent = domain.name; + + btn.append(code, name); + + if (completed) { + const pill = document.createElement("span"); + pill.className = `domain-status-pill ${tierClass(tierForPercentage(completed.percentage))}`; + pill.textContent = `${Math.round(completed.percentage)}%`; + btn.appendChild(pill); + } else { + const status = document.createElement("span"); + status.className = "domain-status"; + status.textContent = "Not started"; + btn.appendChild(status); + } + + btn.addEventListener("click", async () => { + for (const tile of grid.querySelectorAll("button")) tile.disabled = true; + selectionStatus.dataset.state = "submitting"; + selectionStatus.textContent = `Starting ${domain.name} quiz…`; + try { + await postEvent({ type: "select-domain", domainId: domain.id }); + } catch (err) { + for (const tile of grid.querySelectorAll("button")) tile.disabled = false; + selectionStatus.dataset.state = "error"; + selectionStatus.textContent = err instanceof Error ? err.message : "The quiz could not be started. Try again."; + } + }); + + grid.appendChild(btn); + } +} + +function renderQuiz(state) { + const quiz = state.quiz; + if (!quiz) return; + + document.getElementById("quiz-domain-code").textContent = domainCode(quiz.domainId, quiz.domainName); + document.getElementById("quiz-domain-name").textContent = quiz.domainName; + + const question = state.question; + const total = question?.total ?? quiz.total; + const index = question?.index ?? quiz.index; + document.getElementById("quiz-counter").textContent = `Question ${index} of ${total}`; + + // Progress rail + const rail = document.getElementById("progress-rail"); + rail.innerHTML = ""; + const scoredByIndex = new Map(quiz.history.map((h) => [h.index, h.tier])); + for (let i = 1; i <= total; i++) { + const li = document.createElement("li"); + if (scoredByIndex.has(i)) { + li.dataset.state = "scored"; + li.classList.add(tierClass(scoredByIndex.get(i))); + } else if (i === index) { + li.dataset.state = "current"; + } else { + li.dataset.state = "upcoming"; + } + li.setAttribute("aria-label", `Question ${i}${scoredByIndex.has(i) ? " scored" : i === index ? " current" : ""}`); + rail.appendChild(li); + } + + // Question card + const questionCard = document.getElementById("question-card"); + if (question) { + questionCard.dataset.state = "ready"; + document.getElementById("question-type").textContent = question.type; + document.getElementById("question-prompt").textContent = question.prompt; + if (question.id !== activeQuestionId) { + activeQuestionId = question.id; + answerInput.value = ""; + locallySubmitting = false; + localAnswerError = ""; + } + } else { + questionCard.dataset.state = "preparing"; + document.getElementById("question-type").textContent = "Preparing"; + document.getElementById("question-prompt").textContent = "Preparing question…"; + activeQuestionId = null; + answerInput.value = ""; + } + updateAnswerControls(); + updateScoreReveal(); + + document.getElementById("quiz-running-score").textContent = `${quiz.runningScore} / ${quiz.runningMax}`; +} + +function renderSummary(state) { + const summary = state.summary; + if (!summary) return; + + document.getElementById("summary-domain-code").textContent = domainCode(summary.domainId, summary.domainName); + document.getElementById("summary-domain-name").textContent = summary.domainName; + document.getElementById("summary-percentage").textContent = `${Math.round(summary.percentage)}%`; + document.getElementById("summary-fraction").textContent = `${summary.total} / ${summary.max} pts`; + + const tierLabelEl = document.getElementById("summary-tier-label"); + tierLabelEl.textContent = TIER_LABELS[summary.tier] ?? ""; + tierLabelEl.className = `tier-label ${tierClass(summary.tier)}`; + + document.getElementById("summary-strongest").textContent = summary.strongestArea || "—"; + document.getElementById("summary-gap").textContent = summary.gap || "—"; + + const reportButton = document.getElementById("btn-compile-report"); + const reportStatus = document.getElementById("report-request-status"); + const isCompiling = state.reportRequestStatus === "submitting"; + reportButton.disabled = isCompiling; + reportButton.textContent = isCompiling ? "Compiling report…" : "Compile report"; + reportStatus.dataset.state = state.reportRequestStatus || "idle"; + reportStatus.textContent = isCompiling + ? "Building your competency report…" + : state.reportRequestStatus === "error" + ? state.reportRequestError || "The report could not be requested. Try again." + : ""; +} + +function renderReport(state) { + const report = state.report; + if (!report) return; + + const domainCount = report.domains?.length ?? 0; + const verdictEl = document.getElementById("report-verdict"); + const percentageEl = document.createElement("span"); + percentageEl.className = "mono"; + percentageEl.textContent = `${Math.round(report.overallPercentage)}%`; + verdictEl.replaceChildren( + "You scored ", + percentageEl, + ` (${report.overallScore} / ${report.overallMax} pts) across ${domainCount} domain${domainCount === 1 ? "" : "s"}.`, + ); + + const tbody = document.getElementById("report-table-body"); + tbody.innerHTML = ""; + for (const d of report.domains || []) { + const tr = document.createElement("tr"); + const nameCell = document.createElement("td"); + nameCell.textContent = d.name; + const scoreCell = document.createElement("td"); + scoreCell.className = "mono"; + scoreCell.textContent = `${d.score} / ${d.max}`; + const percentageCell = document.createElement("td"); + percentageCell.className = "mono"; + percentageCell.textContent = `${Math.round(d.percentage)}%`; + tr.append(nameCell, scoreCell, percentageCell); + tbody.appendChild(tr); + } + + const strongestEl = document.getElementById("report-strongest"); + strongestEl.innerHTML = ""; + for (const area of report.strongestAreas || []) { + const li = document.createElement("li"); + li.textContent = area; + strongestEl.appendChild(li); + } + + const priorityEl = document.getElementById("report-priority"); + priorityEl.innerHTML = ""; + for (const item of report.priorityAreas || []) { + const div = document.createElement("div"); + div.className = "report-priority-item"; + const h3 = document.createElement("h3"); + h3.textContent = item.name; + div.appendChild(h3); + if (item.concepts?.length) { + const ul = document.createElement("ul"); + for (const concept of item.concepts) { + const li = document.createElement("li"); + li.textContent = concept; + ul.appendChild(li); + } + div.appendChild(ul); + } + priorityEl.appendChild(div); + } + + const recEl = document.getElementById("report-recommendations"); + recEl.innerHTML = ""; + for (const rec of report.recommendations || []) { + const li = document.createElement("li"); + li.textContent = rec; + recEl.appendChild(li); + } + + document.getElementById("report-next-challenge").textContent = report.nextChallenge || ""; +} + +function render(state) { + latestState = state; + showView(state.view); + if (state.view === "domains") renderDomains(state); + if (state.view === "quiz") renderQuiz(state); + if (state.view === "summary") renderSummary(state); + if (state.view === "report") renderReport(state); + announce(state.announcement); +} + +answerInput.addEventListener("input", updateAnswerControls); +answerInput.addEventListener("keydown", (event) => { + if (event.key !== "Enter" || (!event.metaKey && !event.ctrlKey) || submitAnswerButton.disabled) return; + event.preventDefault(); + answerForm.requestSubmit(); +}); +answerForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const answer = answerInput.value.trim(); + const question = latestState?.question; + if (!answer || !question?.id || locallySubmitting) return; + + locallySubmitting = true; + localAnswerError = ""; + updateAnswerControls(); + updateScoreReveal(); + try { + await postEvent({ type: "submit-answer", questionId: question.id, answer }); + } catch (err) { + locallySubmitting = false; + localAnswerError = err instanceof Error ? err.message : "Your answer could not be submitted. Try again."; + updateAnswerControls(); + updateScoreReveal(); + } +}); + +document.getElementById("btn-choose-another").addEventListener("click", () => { + sendEvent({ type: "choose-another-domain" }); +}); +document.getElementById("btn-report-choose-another").addEventListener("click", () => { + sendEvent({ type: "choose-another-domain" }); +}); +document.getElementById("btn-compile-report").addEventListener("click", () => { + sendEvent({ type: "compile-report" }); +}); + +// ---------- Theme toggle ---------- +// Local UI preference only — never sent to the extension/chat. Persisted per +// browser via localStorage so it survives canvas reopens. +const THEME_STORAGE_KEY = "brainmax-canvas-theme"; +const themeToggleBtn = document.getElementById("theme-toggle"); +const themeToggleLabel = document.getElementById("theme-toggle-label"); + +function applyTheme(theme) { + if (theme === "light" || theme === "dark") { + document.documentElement.dataset.theme = theme; + } else { + delete document.documentElement.dataset.theme; + } + const resolved = + theme === "light" || theme === "dark" + ? theme + : window.matchMedia("(prefers-color-scheme: light)").matches + ? "light" + : "dark"; + themeToggleLabel.textContent = resolved.toUpperCase(); +} + +const storedTheme = localStorage.getItem(THEME_STORAGE_KEY); +applyTheme(storedTheme); + +themeToggleBtn.addEventListener("click", () => { + const current = document.documentElement.dataset.theme || (window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"); + const next = current === "light" ? "dark" : "light"; + localStorage.setItem(THEME_STORAGE_KEY, next); + applyTheme(next); +}); + +const source = new EventSource("/events"); +source.onmessage = (e) => { + const state = JSON.parse(e.data); + render(state); +}; +source.onerror = () => { + // EventSource auto-reconnects; nothing to do here besides letting it retry. +}; diff --git a/extensions/brainmax-canvas/public/index.html b/extensions/brainmax-canvas/public/index.html new file mode 100644 index 000000000..4459a714b --- /dev/null +++ b/extensions/brainmax-canvas/public/index.html @@ -0,0 +1,155 @@ + + + + + + BrainMax + + + +
+
+ + BrainMax + concept mastery check + +
+ +
+ + + + + + + + + + + +
+ +
+
+ + + + diff --git a/extensions/brainmax-canvas/public/styles.css b/extensions/brainmax-canvas/public/styles.css new file mode 100644 index 000000000..e4439b253 --- /dev/null +++ b/extensions/brainmax-canvas/public/styles.css @@ -0,0 +1,906 @@ +/* BrainMax canvas — "assessment terminal" visual system. Tokens mirror + DESIGN.md at the project root; keep them in sync if either changes. */ + +/* ---------- Theme tokens ---------- + Dark is the default identity ("assessment terminal" — a focused evening + check-in). Light is a fully-considered second theme, not an inversion: + different surface strategy (shadow-based elevation vs. lighter-surface + stacking) and darkened brand/tier colors so text-on-color still clears + WCAG AA on a bright surface. Resolution order: explicit [data-theme] + attribute (user toggle, persisted) > prefers-color-scheme > dark default. */ + +:root { + /* Surfaces */ + --bg: oklch(0.09 0 0); + --surface: oklch(0.15 0.012 294); + --surface-2: oklch(0.2 0.014 294); + --border: oklch(0.32 0.018 294); + --border-strong: oklch(0.46 0.03 294); + --shadow-sm: none; + --shadow-md: none; + + /* Text */ + --ink: oklch(0.95 0.006 294); + --muted: oklch(0.7 0.014 294); + --faint: oklch(0.52 0.014 294); + + /* Brand */ + --primary: oklch(0.62 0.16 294); + --primary-ink: oklch(0.99 0 0); + --primary-dim: oklch(0.3 0.05 294); + --accent: oklch(0.75 0.15 75); + --accent-ink: oklch(0.16 0.02 75); + + /* Score tiers (0-3) */ + --tier-0: oklch(0.62 0.19 25); + --tier-1: oklch(0.7 0.15 55); + --tier-2: oklch(0.74 0.12 190); + --tier-3: oklch(0.72 0.14 145); +} + +/* Auto light mode: only applies when the user hasn't explicitly toggled + (no [data-theme] attribute set yet by app.js). */ +@media (prefers-color-scheme: light) { + :root:not([data-theme]) { + --bg: oklch(1 0 0); + --surface: oklch(0.98 0.004 294); + --surface-2: oklch(0.955 0.008 294); + --border: oklch(0.87 0.01 294); + --border-strong: oklch(0.74 0.02 294); + --shadow-sm: 0 1px 2px oklch(0.2 0.02 294 / 0.06); + --shadow-md: 0 4px 16px oklch(0.2 0.02 294 / 0.08); + + --ink: oklch(0.2 0.012 294); + --muted: oklch(0.42 0.016 294); + --faint: oklch(0.55 0.016 294); + + --primary: oklch(0.5 0.17 294); + --primary-ink: oklch(0.99 0 0); + --primary-dim: oklch(0.93 0.025 294); + --accent: oklch(0.48 0.15 75); + --accent-ink: oklch(0.99 0 0); + + --tier-0: oklch(0.52 0.19 25); + --tier-1: oklch(0.55 0.15 55); + --tier-2: oklch(0.5 0.11 190); + --tier-3: oklch(0.48 0.13 145); + } +} + +[data-theme="light"] { + --bg: oklch(1 0 0); + --surface: oklch(0.98 0.004 294); + --surface-2: oklch(0.955 0.008 294); + --border: oklch(0.87 0.01 294); + --border-strong: oklch(0.74 0.02 294); + --shadow-sm: 0 1px 2px oklch(0.2 0.02 294 / 0.06); + --shadow-md: 0 4px 16px oklch(0.2 0.02 294 / 0.08); + + --ink: oklch(0.2 0.012 294); + --muted: oklch(0.42 0.016 294); + --faint: oklch(0.55 0.016 294); + + --primary: oklch(0.5 0.17 294); + --primary-ink: oklch(0.99 0 0); + --primary-dim: oklch(0.93 0.025 294); + --accent: oklch(0.48 0.15 75); + --accent-ink: oklch(0.99 0 0); + + --tier-0: oklch(0.52 0.19 25); + --tier-1: oklch(0.55 0.15 55); + --tier-2: oklch(0.5 0.11 190); + --tier-3: oklch(0.48 0.13 145); +} + +[data-theme="dark"] { + --bg: oklch(0.09 0 0); + --surface: oklch(0.15 0.012 294); + --surface-2: oklch(0.2 0.014 294); + --border: oklch(0.32 0.018 294); + --border-strong: oklch(0.46 0.03 294); + --shadow-sm: none; + --shadow-md: none; + + --ink: oklch(0.95 0.006 294); + --muted: oklch(0.7 0.014 294); + --faint: oklch(0.52 0.014 294); + + --primary: oklch(0.62 0.16 294); + --primary-ink: oklch(0.99 0 0); + --primary-dim: oklch(0.3 0.05 294); + --accent: oklch(0.75 0.15 75); + --accent-ink: oklch(0.16 0.02 75); + + --tier-0: oklch(0.62 0.19 25); + --tier-1: oklch(0.7 0.15 55); + --tier-2: oklch(0.74 0.12 190); + --tier-3: oklch(0.72 0.14 145); +} + +:root { + /* Type */ + --font-sans: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace; + --text-xs: 0.75rem; + --text-sm: 0.875rem; + --text-base: 1rem; + --text-lg: 1.25rem; + --text-xl: 1.75rem; + --text-2xl: 2.5rem; + + /* Space (4pt scale) */ + --space-xs: 0.25rem; + --space-sm: 0.5rem; + --space-md: 1rem; + --space-lg: 1.5rem; + --space-xl: 3rem; + --space-2xl: 6rem; + + /* Motion */ + --ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1); + --duration-fast: 150ms; + --duration-base: 220ms; + --duration-reveal: 260ms; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body { + height: 100%; +} + +:root { + color-scheme: dark; +} + +[data-theme="light"] { + color-scheme: light; +} + +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font-family: var(--font-sans); + font-size: var(--text-base); + line-height: 1.5; + -webkit-font-smoothing: antialiased; + transition: background-color var(--duration-base) var(--ease-out-quart), color var(--duration-base) var(--ease-out-quart); +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; + border-radius: 4px; +} + +.mono { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; +} + +/* ---------- App shell ---------- */ + +.app { + min-height: 100%; + display: flex; + flex-direction: column; +} + +.app-header { + display: flex; + align-items: baseline; + gap: var(--space-sm); + padding: var(--space-lg) var(--space-xl); + border-bottom: 1px solid var(--border); +} + +.app-mark { + color: var(--primary); + font-size: var(--text-lg); +} + +.app-title { + font-weight: 700; + font-size: var(--text-lg); + letter-spacing: -0.01em; +} + +.app-subtitle { + color: var(--muted); + font-size: var(--text-sm); +} + +.theme-toggle { + margin-left: auto; + align-self: center; + display: inline-flex; + align-items: center; + gap: var(--space-xs); + background: transparent; + border: 1px solid var(--border); + color: var(--muted); + font-family: var(--font-mono); + font-size: var(--text-xs); + letter-spacing: 0.04em; + border-radius: 999px; + padding: 0.35em 0.8em; + cursor: pointer; + transition: border-color var(--duration-fast) var(--ease-out-quart), color var(--duration-fast) var(--ease-out-quart); +} + +.theme-toggle:hover, +.theme-toggle:focus-visible { + border-color: var(--border-strong); + color: var(--ink); +} + +/* ---------- View stack (crossfade router) ---------- */ + +.view-stack { + flex: 1; + position: relative; + padding: var(--space-xl); +} + +.view { + max-width: 760px; + margin: 0 auto; + animation: view-enter var(--duration-base) var(--ease-out-quart); +} + +@keyframes view-enter { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.view-heading { + font-size: var(--text-xl); + font-weight: 700; + letter-spacing: -0.01em; + margin: 0 0 var(--space-sm); +} + +.view-lede { + color: var(--muted); + max-width: 62ch; + margin: 0 0 var(--space-xl); +} + +.mono-tag { + display: inline-block; + font-family: var(--font-mono); + font-size: var(--text-xs); + font-weight: 600; + letter-spacing: 0.06em; + color: var(--primary); + background: var(--primary-dim); + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.15em 0.5em; + margin-bottom: var(--space-sm); +} + +/* ---------- Domain grid ---------- */ + +.domain-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: var(--space-md); +} + +.domain-tile { + text-align: left; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: var(--space-lg); + cursor: pointer; + color: inherit; + font: inherit; + display: flex; + flex-direction: column; + gap: var(--space-sm); + box-shadow: var(--shadow-sm); + transition: transform var(--duration-fast) var(--ease-out-quart), border-color var(--duration-fast) var(--ease-out-quart), + background var(--duration-fast) var(--ease-out-quart), box-shadow var(--duration-fast) var(--ease-out-quart); +} + +.domain-tile:hover, +.domain-tile:focus-visible { + border-color: var(--border-strong); + background: var(--surface-2); + transform: scale(1.02); + box-shadow: var(--shadow-md); +} + +.domain-tile:active { + transform: scale(0.99); +} + +.domain-code { + font-family: var(--font-mono); + font-size: var(--text-xs); + font-weight: 600; + letter-spacing: 0.08em; + color: var(--muted); +} + +.domain-name { + font-weight: 600; + font-size: var(--text-base); +} + +.domain-status { + font-size: var(--text-sm); + color: var(--faint); +} + +.domain-status-pill { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + font-family: var(--font-mono); + font-size: var(--text-xs); + font-weight: 600; + color: var(--primary-ink); + padding: 0.2em 0.6em; + border-radius: 999px; + width: fit-content; +} + +.domain-tile:disabled { + cursor: wait; + opacity: 0.65; + transform: none; +} + +.domain-selection-status { + min-height: 1.5rem; + margin: var(--space-md) 0 0; + color: var(--muted); + font-size: var(--text-sm); +} + +.domain-selection-status[data-state="error"] { + color: var(--tier-0); +} + +.report-request-status { + min-height: 1.5rem; + margin: var(--space-sm) 0 0; + color: var(--muted); + font-size: var(--text-sm); + text-align: right; +} + +.report-request-status[data-state="error"] { + color: var(--tier-0); +} + +/* ---------- Quiz view ---------- */ + +.quiz-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: var(--space-md); + margin-bottom: var(--space-lg); +} + +.quiz-header-labels .view-heading { + margin-bottom: 0; +} + +.quiz-counter { + font-size: var(--text-sm); + color: var(--muted); + white-space: nowrap; +} + +.progress-rail { + display: flex; + gap: var(--space-sm); + list-style: none; + padding: 0; + margin: 0 0 var(--space-xl); +} + +.progress-rail li { + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid var(--border-strong); + background: transparent; + transition: background var(--duration-base) var(--ease-out-quart), border-color var(--duration-base) var(--ease-out-quart), + transform var(--duration-base) var(--ease-out-quart); +} + +.progress-rail li[data-state="current"] { + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--primary-dim); +} + +.progress-rail li[data-state="scored"] { + border-color: transparent; + transform: scale(1.05); +} + +.question-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: var(--space-xl); + margin-bottom: var(--space-lg); + box-shadow: var(--shadow-sm); +} + +.question-card[data-state="preparing"] { + color: var(--muted); +} + +.question-type-tag { + display: inline-block; + font-family: var(--font-mono); + font-size: var(--text-xs); + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--accent); + margin-bottom: var(--space-md); +} + +.question-prompt { + font-size: var(--text-lg); + line-height: 1.5; + margin: 0; + max-width: 62ch; + overflow-wrap: anywhere; +} + +.answer-form { + margin-bottom: var(--space-lg); +} + +.answer-label { + display: block; + margin-bottom: var(--space-sm); + color: var(--muted); + font-size: var(--text-sm); + font-weight: 600; +} + +.answer-input { + display: block; + width: 100%; + min-height: 9rem; + resize: vertical; + padding: var(--space-md); + border: 1px solid var(--border-strong); + border-radius: 8px; + background: var(--surface); + color: var(--ink); + font: inherit; + line-height: 1.5; + box-shadow: var(--shadow-sm); + transition: border-color var(--duration-fast) var(--ease-out-quart), background var(--duration-fast) var(--ease-out-quart); +} + +.answer-input::placeholder { + color: var(--faint); +} + +.answer-input:hover:not(:disabled) { + border-color: var(--primary); +} + +.answer-input:disabled { + background: var(--surface-2); + color: var(--muted); + cursor: not-allowed; +} + +.answer-actions { + min-height: 2.75rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + margin-top: var(--space-sm); +} + +.answer-status { + margin: 0; + color: var(--muted); + font-size: var(--text-sm); +} + +.answer-status[data-state="error"] { + color: var(--tier-0); +} + +.score-reveal { + display: flex; + align-items: center; + gap: var(--space-md); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: var(--space-md) var(--space-lg); + margin-bottom: var(--space-lg); + box-shadow: var(--shadow-md); + animation: score-reveal-in var(--duration-reveal) var(--ease-out-quart); +} + +@keyframes score-reveal-in { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.score-chip { + flex-shrink: 0; + font-size: var(--text-lg); + font-weight: 600; + color: var(--primary-ink); + padding: var(--space-sm) var(--space-md); + border-radius: 8px; +} + +.score-feedback { + margin: 0; + color: var(--muted); +} + +.quiz-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: var(--space-md); + border-top: 1px solid var(--border); +} + +.quiz-footer-label { + color: var(--muted); + font-size: var(--text-sm); +} + +.quiz-running-score { + font-weight: 600; + font-size: var(--text-lg); +} + +/* ---------- Summary view ---------- */ + +.summary-score-block { + display: flex; + align-items: baseline; + gap: var(--space-lg); + margin: var(--space-lg) 0 var(--space-xl); +} + +.summary-percentage { + font-size: var(--text-2xl); + font-weight: 600; + line-height: 1; +} + +.summary-score-detail { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.summary-fraction { + color: var(--muted); + font-size: var(--text-sm); +} + +.tier-label { + font-weight: 600; + font-size: var(--text-sm); + width: fit-content; + padding: 0.15em 0.6em; + border-radius: 999px; +} + +.summary-notes { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: var(--space-lg); + margin: 0 0 var(--space-xl); + padding: var(--space-lg) 0; + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} + +.summary-note dt { + font-size: var(--text-xs); + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--faint); + margin-bottom: var(--space-xs); +} + +.summary-note dd { + margin: 0; + color: var(--ink); +} + +/* ---------- Report view ---------- */ + +.report-verdict { + font-size: var(--text-lg); + max-width: 65ch; + margin: 0 0 var(--space-xl); +} + +.report-verdict .mono { + font-weight: 600; + color: var(--primary); +} + +.report-table { + width: 100%; + border-collapse: collapse; + margin-bottom: var(--space-xl); +} + +.report-table th, +.report-table td { + text-align: left; + padding: var(--space-sm) var(--space-md); + border-bottom: 1px solid var(--border); +} + +.report-table th { + font-size: var(--text-xs); + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--faint); + font-weight: 600; +} + +.report-table td.mono { + color: var(--muted); +} + +.report-columns { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: var(--space-xl); + margin-bottom: var(--space-xl); +} + +.report-col-heading { + font-size: var(--text-base); + font-weight: 700; + margin: 0 0 var(--space-md); +} + +.report-list { + margin: 0; + padding-left: var(--space-lg); + color: var(--muted); +} + +.report-list li { + margin-bottom: var(--space-sm); +} + +.report-priority { + display: flex; + flex-direction: column; + gap: var(--space-md); +} + +.report-priority-item { + border: 1px solid var(--border); + border-radius: 8px; + padding: var(--space-md); +} + +.report-priority-item h3 { + margin: 0 0 var(--space-xs); + font-size: var(--text-sm); + color: var(--tier-0); +} + +.report-priority-item ul { + margin: 0; + padding-left: var(--space-lg); + color: var(--muted); + font-size: var(--text-sm); +} + +.callout { + border: 1px solid var(--border-strong); + border-radius: 12px; + padding: var(--space-lg); + margin-bottom: var(--space-xl); + box-shadow: var(--shadow-sm); +} + +.callout-label { + display: block; + font-family: var(--font-mono); + font-size: var(--text-xs); + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--accent); + margin-bottom: var(--space-sm); +} + +.callout p { + margin: 0; + max-width: 65ch; +} + +/* ---------- Buttons ---------- */ + +.action-row { + display: flex; + gap: var(--space-md); + flex-wrap: wrap; +} + +.btn { + font-family: var(--font-sans); + font-size: var(--text-sm); + font-weight: 600; + padding: var(--space-sm) var(--space-lg); + border-radius: 8px; + border: 1px solid transparent; + cursor: pointer; + transition: transform var(--duration-fast) var(--ease-out-quart), border-color var(--duration-fast) var(--ease-out-quart), + background var(--duration-fast) var(--ease-out-quart); +} + +.btn:hover { + transform: scale(1.02); +} + +.btn:active { + transform: scale(0.98); +} + +.btn:disabled, +.btn:disabled:hover { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.btn-primary { + background: var(--primary); + color: var(--primary-ink); +} + +.btn-primary:hover { + background: oklch(from var(--primary) calc(l + 0.05) c h); +} + +.btn-secondary { + background: transparent; + color: var(--ink); + border-color: var(--border-strong); +} + +.btn-secondary:hover { + background: var(--surface-2); +} + +/* ---------- Tier color utility classes (applied via JS) ---------- */ + +.tier-0 { + background: var(--tier-0); +} +.tier-1 { + background: var(--tier-1); +} +.tier-2 { + background: var(--tier-2); +} +.tier-3 { + background: var(--tier-3); +} +.progress-rail li.tier-0 { + background: var(--tier-0); +} +.progress-rail li.tier-1 { + background: var(--tier-1); +} +.progress-rail li.tier-2 { + background: var(--tier-2); +} +.progress-rail li.tier-3 { + background: var(--tier-3); +} +.tier-label.tier-0 { + color: var(--tier-0); + background: color-mix(in oklch, var(--tier-0) 16%, transparent); +} +.tier-label.tier-1 { + color: var(--tier-1); + background: color-mix(in oklch, var(--tier-1) 16%, transparent); +} +.tier-label.tier-2 { + color: var(--tier-2); + background: color-mix(in oklch, var(--tier-2) 16%, transparent); +} +.tier-label.tier-3 { + color: var(--tier-3); + background: color-mix(in oklch, var(--tier-3) 16%, transparent); +} + +/* ---------- Responsive ---------- */ + +@media (max-width: 640px) { + .app-header { + padding: var(--space-md) var(--space-lg); + } + .app-subtitle { + display: none; + } + .view-stack { + padding: var(--space-lg) var(--space-md); + } + .quiz-header { + flex-direction: column; + align-items: flex-start; + gap: var(--space-xs); + } + .summary-score-block { + flex-direction: column; + gap: var(--space-sm); + } + .answer-actions { + align-items: stretch; + flex-direction: column; + } + .answer-actions .btn { + width: 100%; + } +} + +/* ---------- Reduced motion ---------- */ + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +}