Apply ZIP update: 215-didactopus-live-learner-ui-prototype.zip [2026-03-14T13:20:39]

This commit is contained in:
welsberr 2026-03-14 13:29:56 -04:00
parent 82f827b8bd
commit 03ceea79fe
9 changed files with 452 additions and 234 deletions

View File

@ -6,17 +6,7 @@ build-backend = "setuptools.build_meta"
name = "didactopus"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
"pydantic>=2.7",
"fastapi>=0.115",
"uvicorn>=0.30",
"sqlalchemy>=2.0",
"passlib[bcrypt]>=1.7",
"python-jose[cryptography]>=3.3"
]
[project.scripts]
didactopus-api = "didactopus.api:main"
dependencies = ["pydantic>=2.7", "pyyaml>=6.0"]
[tool.setuptools.packages.find]
where = ["src"]

View File

@ -1,10 +1,6 @@
from __future__ import annotations
from datetime import datetime, timezone
from .learner_state import LearnerState, EvidenceEvent, MasteryRecord
def _parse_ts(ts: str) -> datetime:
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
def apply_evidence(
state: LearnerState,
event: EvidenceEvent,
@ -23,34 +19,13 @@ def apply_evidence(
)
state.records.append(rec)
prev_score = rec.score
prev_conf = rec.confidence
# weighted incremental update
weight = max(0.05, min(1.0, event.confidence_hint))
rec.score = ((prev_score * rec.evidence_count) + (event.score * weight)) / max(1, rec.evidence_count + 1)
# confidence grows with repeated evidence and quality, but is bounded
rec.score = ((rec.score * rec.evidence_count) + (event.score * weight)) / max(1, rec.evidence_count + 1)
rec.confidence = min(
1.0,
max(
0.0,
prev_conf * (1.0 - decay) + reinforcement * weight + 0.10 * max(0.0, min(1.0, event.score))
),
max(0.0, rec.confidence * (1.0 - decay) + reinforcement * weight + 0.10 * max(0.0, min(1.0, event.score))),
)
rec.evidence_count += 1
rec.last_updated = event.timestamp
state.history.append(event)
return state
def decay_confidence(state: LearnerState, now_ts: str, daily_decay: float = 0.0025) -> LearnerState:
now = _parse_ts(now_ts)
for rec in state.records:
if not rec.last_updated:
continue
then = _parse_ts(rec.last_updated)
delta_days = max(0.0, (now - then).total_seconds() / 86400.0)
factor = max(0.0, 1.0 - daily_decay * delta_days)
rec.confidence = max(0.0, rec.confidence * factor)
return state

View File

@ -2,5 +2,5 @@ from pathlib import Path
def test_ui_files_exist():
assert Path("webui/src/App.jsx").exists()
assert Path("webui/src/storage.js").exists()
assert Path("webui/public/packs/bayes-pack.json").exists()
assert Path("webui/src/domainData.js").exists()
assert Path("webui/src/engine.js").exists()

View File

@ -3,8 +3,10 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Didactopus Learning Animation</title>
<title>Didactopus Live Learner Prototype</title>
<script type="module" src="/src/main.jsx"></script>
</head>
<body><div id="root"></div></body>
<body>
<div id="root"></div>
</body>
</html>

View File

@ -1,9 +1,17 @@
{
"name": "didactopus-learning-animation-ui",
"name": "didactopus-live-learner-ui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": { "dev": "vite", "build": "vite build" },
"dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" },
"devDependencies": { "vite": "^5.4.0" }
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"vite": "^5.4.0"
}
}

View File

@ -1,184 +1,236 @@
import React, { useEffect, useMemo, useState } from "react";
import { login, refresh, fetchPacks, fetchLearnerState, putLearnerState, createLearnerRun, addWorkflowEvent, fetchAnimation } from "./api";
import { loadAuth, saveAuth, clearAuth } from "./authStore";
import React, { useMemo, useState } from "react";
import { domains } from "./domainData";
import {
applyEvidence,
buildMasteryMap,
claimReadiness,
milestoneMessages,
progressPercent,
recommendNext,
starterLearnerState
} from "./engine";
function LoginView({ onAuth }) {
const [username, setUsername] = useState("wesley");
const [password, setPassword] = useState("demo-pass");
const [error, setError] = useState("");
async function doLogin() {
try {
const result = await login(username, password);
saveAuth(result);
onAuth(result);
} catch { setError("Login failed"); }
}
function DomainCard({ domain, selected, onSelect }) {
return (
<div className="page narrow-page">
<section className="card narrow">
<h1>Didactopus login</h1>
<label>Username<input value={username} onChange={(e) => setUsername(e.target.value)} /></label>
<label>Password<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} /></label>
<button className="primary" onClick={doLogin}>Login</button>
{error ? <div className="error">{error}</div> : null}
</section>
<button className={`domain-card ${selected ? "selected" : ""}`} onClick={() => onSelect(domain.id)}>
<div className="domain-title">{domain.title}</div>
<div className="domain-subtitle">{domain.subtitle}</div>
<div className="domain-meta">
<span>{domain.level}</span>
<span>{domain.concepts.length} concepts</span>
</div>
</button>
);
}
function OnboardingPanel({ domain, learnerName }) {
return (
<section className="card">
<h2>First-session onboarding</h2>
<h3>{domain.onboarding.headline}</h3>
<p>{domain.onboarding.body}</p>
<p className="muted">Learner: {learnerName || "Unnamed learner"}</p>
<ul>
{domain.onboarding.checklist.map((item, idx) => <li key={idx}>{item}</li>)}
</ul>
</section>
);
}
function NextStepCard({ step, onSimulate }) {
return (
<div className="step-card">
<div className="step-header">
<div>
<h3>{step.title}</h3>
<div className="muted">{step.minutes} minutes</div>
</div>
<div className="reward-pill">{step.reward}</div>
</div>
<p>{step.reason}</p>
<details>
<summary>Why this is recommended</summary>
<ul>
{step.why.map((item, idx) => <li key={idx}>{item}</li>)}
</ul>
</details>
<button className="primary" onClick={() => onSimulate(step)}>Simulate completing this step</button>
</div>
);
}
function AnimationBars({ frame, concepts }) {
if (!frame) return null;
function MasteryMap({ nodes }) {
return (
<div className="bars">
{concepts.map((concept) => {
const value = frame.scores?.[concept] ?? 0;
return (
<div className="bar-row" key={concept}>
<div className="bar-label">{concept}</div>
<div className="bar-track">
<div className="bar-fill" style={{ width: `${Math.max(0, Math.min(100, value * 100))}%` }} />
</div>
<div className="bar-value">{value.toFixed(2)}</div>
<section className="card">
<h2>Visible mastery map</h2>
<div className="map-grid">
{nodes.map((node) => (
<div key={node.id} className={`map-node ${node.status}`}>
<div className="node-label">{node.label}</div>
<div className="node-status">{node.status}</div>
</div>
);
})}
</div>
))}
</div>
</section>
);
}
function ProgressPanel({ progress, readiness }) {
return (
<section className="card">
<h2>Progress</h2>
<div className="progress-wrap">
<div className="progress-label">Mastery progress</div>
<div className="progress-bar"><div className="progress-fill" style={{ width: `${progress}%` }} /></div>
<div className="muted">{progress}%</div>
</div>
<div className={`readiness-box ${readiness.ready ? "ready" : ""}`}>
<strong>{readiness.ready ? "Usable expertise threshold met" : "Still building toward usable expertise"}</strong>
<div className="muted">Mastered concepts: {readiness.mastered}</div>
<div className="muted">Average score: {readiness.avgScore.toFixed(2)}</div>
<div className="muted">Average confidence: {readiness.avgConfidence.toFixed(2)}</div>
</div>
</section>
);
}
function MilestonePanel({ milestones, lastReward }) {
return (
<section className="card">
<h2>Milestones and rewards</h2>
{lastReward ? <div className="reward-banner">{lastReward}</div> : null}
<ul>
{milestones.map((m, idx) => <li key={idx}>{m}</li>)}
</ul>
</section>
);
}
function CompliancePanel({ compliance }) {
return (
<section className="card">
<h2>Source attribution and compliance</h2>
<div className="compliance-grid">
<div><strong>Sources</strong><br />{compliance.sources}</div>
<div><strong>Attribution</strong><br />{compliance.attributionRequired ? "required" : "not required"}</div>
<div><strong>Share-alike</strong><br />{compliance.shareAlikeRequired ? "yes" : "no"}</div>
<div><strong>Noncommercial</strong><br />{compliance.noncommercialOnly ? "yes" : "no"}</div>
</div>
<div className="flag-row">
{compliance.flags.length ? compliance.flags.map((f) => <span className="flag" key={f}>{f}</span>) : <span className="muted">No extra flags</span>}
</div>
<p className="muted">
Provenance-sensitive packs should remain inspectable so that learners and maintainers do not have to guess at reuse constraints.
</p>
</section>
);
}
function EvidenceLog({ history }) {
return (
<section className="card">
<h2>Evidence log</h2>
{history.length === 0 ? (
<div className="muted">No evidence recorded yet.</div>
) : (
<ul>
{history.slice().reverse().map((item, idx) => (
<li key={idx}>
{item.concept_id} · score {item.score.toFixed(2)} · confidence hint {item.confidence_hint.toFixed(2)}
</li>
))}
</ul>
)}
</section>
);
}
export default function App() {
const [auth, setAuth] = useState(loadAuth());
const [packs, setPacks] = useState([]);
const [learnerId] = useState("wesley-learner");
const [packId, setPackId] = useState("");
const [animation, setAnimation] = useState(null);
const [frameIndex, setFrameIndex] = useState(0);
const [playing, setPlaying] = useState(false);
const [message, setMessage] = useState("");
const [learnerName, setLearnerName] = useState("Wesley");
const [selectedDomainId, setSelectedDomainId] = useState(domains[0].id);
const [domainStates, setDomainStates] = useState(() =>
Object.fromEntries(domains.map((d) => [d.id, starterLearnerState(`learner-${d.id}`)]))
);
const [lastReward, setLastReward] = useState("");
async function refreshAuthToken() {
if (!auth?.refresh_token) return null;
try {
const result = await refresh(auth.refresh_token);
saveAuth(result);
setAuth(result);
return result;
} catch {
clearAuth();
setAuth(null);
return null;
}
const domain = useMemo(() => domains.find((d) => d.id === selectedDomainId) || domains[0], [selectedDomainId]);
const learnerState = domainStates[selectedDomainId];
const masteryMap = useMemo(() => buildMasteryMap(learnerState, domain), [learnerState, domain]);
const progress = useMemo(() => progressPercent(learnerState, domain), [learnerState, domain]);
const recs = useMemo(() => recommendNext(learnerState, domain), [learnerState, domain]);
const milestones = useMemo(() => milestoneMessages(learnerState, domain), [learnerState, domain]);
const readiness = useMemo(() => claimReadiness(learnerState, domain), [learnerState, domain]);
function simulateStep(step) {
const timestamp = new Date().toISOString();
const updated = applyEvidence(learnerState, {
concept_id: step.conceptId,
dimension: "mastery",
score: step.scoreHint,
confidence_hint: step.confidenceHint,
timestamp,
kind: "checkpoint",
source_id: `ui-${step.id}`
});
setDomainStates((prev) => ({ ...prev, [selectedDomainId]: updated }));
setLastReward(step.reward);
}
async function guarded(fn) {
try { return await fn(auth.access_token); }
catch {
const next = await refreshAuthToken();
if (!next) throw new Error("auth failed");
return await fn(next.access_token);
}
function resetDomain() {
setDomainStates((prev) => ({ ...prev, [selectedDomainId]: starterLearnerState(`learner-${selectedDomainId}`) }));
setLastReward("");
}
async function reloadAnimation(pid) {
const data = await guarded((token) => fetchAnimation(token, learnerId, pid));
setAnimation(data);
setFrameIndex(0);
}
useEffect(() => {
if (!auth) return;
async function load() {
const p = await guarded((token) => fetchPacks(token));
setPacks(p);
const pid = p[0]?.id || "";
setPackId(pid);
if (pid) await reloadAnimation(pid);
}
load();
}, [auth]);
useEffect(() => {
if (!playing || !animation?.frames?.length) return;
const t = setInterval(() => {
setFrameIndex((idx) => {
if (idx >= animation.frames.length - 1) {
return 0;
}
return idx + 1;
});
}, 900);
return () => clearInterval(t);
}, [playing, animation]);
const currentFrame = animation?.frames?.[frameIndex];
const concepts = useMemo(() => animation?.concepts || [], [animation]);
async function simulateLearning() {
const run = await guarded((token) => createLearnerRun(token, { learner_id: learnerId, pack_id: packId, title: "Demo animation run", actor_kind: "human" }));
let state = await guarded((token) => fetchLearnerState(token, learnerId));
const now1 = new Date().toISOString();
state.history.push({ concept_id: "intro", dimension: "mastery", score: 0.35, confidence_hint: 0.5, timestamp: now1, kind: "exercise", source_id: "demo-1" });
state.records = [{ concept_id: "intro", dimension: "mastery", score: 0.35, confidence: 0.35, evidence_count: 1, last_updated: now1 }];
await guarded((token) => putLearnerState(token, learnerId, state));
await guarded((token) => addWorkflowEvent(token, { run_id: run.run_id, learner_id: learnerId, event_type: "exercise_completed", concept_id: "intro", timestamp: now1, detail: { score: 0.35 } }));
const now2 = new Date(Date.now() + 1000).toISOString();
state.history.push({ concept_id: "intro", dimension: "mastery", score: 0.75, confidence_hint: 0.7, timestamp: now2, kind: "review", source_id: "demo-2" });
state.records = [{ concept_id: "intro", dimension: "mastery", score: 0.75, confidence: 0.68, evidence_count: 2, last_updated: now2 }];
await guarded((token) => putLearnerState(token, learnerId, state));
await guarded((token) => addWorkflowEvent(token, { run_id: run.run_id, learner_id: learnerId, event_type: "review_completed", concept_id: "intro", timestamp: now2, detail: { score: 0.75 } }));
const now3 = new Date(Date.now() + 2000).toISOString();
state.history.push({ concept_id: "second", dimension: "mastery", score: 0.45, confidence_hint: 0.5, timestamp: now3, kind: "exercise", source_id: "demo-3" });
state.records = [
{ concept_id: "intro", dimension: "mastery", score: 0.75, confidence: 0.68, evidence_count: 2, last_updated: now2 },
{ concept_id: "second", dimension: "mastery", score: 0.45, confidence: 0.40, evidence_count: 1, last_updated: now3 },
];
await guarded((token) => putLearnerState(token, learnerId, state));
await guarded((token) => addWorkflowEvent(token, { run_id: run.run_id, learner_id: learnerId, event_type: "unlock_progress", concept_id: "second", timestamp: now3, detail: { score: 0.45 } }));
await reloadAnimation(packId);
setMessage(`Demo run ${run.run_id} generated animation frames.`);
}
if (!auth) return <LoginView onAuth={setAuth} />;
return (
<div className="page">
<header className="hero">
<div>
<h1>Didactopus learning animation</h1>
<p>Replay mastery changes for human or AI learners over time.</p>
<div className="muted">{message}</div>
<h1>Didactopus learner prototype</h1>
<p>
Pick a topic, get a clear onboarding path, see live progress, and inspect exactly why the system recommends each next step.
</p>
</div>
<div className="controls">
<label>Pack
<select value={packId} onChange={async (e) => { setPackId(e.target.value); await reloadAnimation(e.target.value); }}>
{packs.map((p) => <option key={p.id} value={p.id}>{p.title}</option>)}
</select>
<div className="hero-controls">
<label>
Learner name
<input value={learnerName} onChange={(e) => setLearnerName(e.target.value)} />
</label>
<button onClick={() => setPlaying((x) => !x)}>{playing ? "Pause" : "Play"}</button>
<button onClick={simulateLearning}>Generate demo run</button>
<button onClick={() => { clearAuth(); setAuth(null); }}>Logout</button>
<button onClick={resetDomain}>Reset selected domain</button>
</div>
</header>
<main className="layout twocol">
<section className="card">
<h2>Current frame</h2>
<div className="frame-meta">
<div><strong>Frame:</strong> {frameIndex + 1} / {animation?.frames?.length || 0}</div>
<div><strong>Event:</strong> {currentFrame?.event_kind || "-"}</div>
<div><strong>Concept:</strong> {currentFrame?.concept_id || "-"}</div>
<div><strong>Timestamp:</strong> {currentFrame?.timestamp || "-"}</div>
</div>
<AnimationBars frame={currentFrame} concepts={concepts} />
</section>
<section className="domain-grid">
{domains.map((d) => (
<DomainCard key={d.id} domain={d} selected={d.id === domain.id} onSelect={setSelectedDomainId} />
))}
</section>
<section className="card">
<h2>Animation data</h2>
<pre className="prebox">{JSON.stringify(animation, null, 2)}</pre>
</section>
<main className="layout">
<div className="left-col">
<OnboardingPanel domain={domain} learnerName={learnerName} />
<MasteryMap nodes={masteryMap} />
<EvidenceLog history={learnerState.history} />
</div>
<div className="center-col">
<section className="card">
<h2>What should I do next?</h2>
{recs.length === 0 ? (
<div className="muted">No immediate recommendation available. You may have completed the current progression or need a new pack.</div>
) : (
<div className="steps-stack">
{recs.map((step) => <NextStepCard key={step.id} step={step} onSimulate={simulateStep} />)}
</div>
)}
</section>
</div>
<div className="right-col">
<ProgressPanel progress={progress} readiness={readiness} />
<MilestonePanel milestones={milestones} lastReward={lastReward} />
<CompliancePanel compliance={domain.compliance} />
</div>
</main>
</div>
);

View File

@ -73,19 +73,21 @@ export function recommendNext(state, domain) {
minutes: status === "available" ? 15 : 10,
reason: status === "available"
? "Prerequisites are satisfied, so this is the best next unlock."
: "You have started this concept, but mastery is not yet secure.",
: "You have started this concept, but the system does not yet consider mastery secure.",
why: [
"Prerequisite check passed",
rec ? `Current score: ${rec.score.toFixed(2)}` : "No evidence recorded yet",
rec ? `Current confidence: ${rec.confidence.toFixed(2)}` : "Confidence starts after your first exercise"
rec ? `Current confidence: ${rec.confidence.toFixed(2)}` : "Confidence will start growing after your first exercise"
],
reward: concept.exerciseReward || `${concept.title} progress recorded`,
reward: concept.exerciseReward,
conceptId: concept.id,
scoreHint: status === "available" ? 0.82 : 0.76,
confidenceHint: status === "available" ? 0.72 : 0.55
});
}
}
// reinforcement targets
for (const rec of state.records) {
if (rec.dimension === "mastery" && rec.confidence < 0.40) {
const concept = domain.concepts.find((c) => c.id === rec.concept_id);
@ -97,7 +99,7 @@ export function recommendNext(state, domain) {
reason: "Your score is promising, but confidence is still thin.",
why: [
`Confidence ${rec.confidence.toFixed(2)} is below reinforcement threshold`,
"A small fresh exercise can stabilize recall"
"A small fresh exercise can stabilize recall and raise readiness"
],
reward: "Confidence ring grows",
conceptId: concept.id,
@ -107,6 +109,7 @@ export function recommendNext(state, domain) {
}
}
}
return cards.slice(0, 4);
}
@ -126,7 +129,10 @@ export function claimReadiness(state, domain, minScore = 0.75, minConfidence = 0
return rec && rec.score >= minScore && rec.confidence >= minConfidence;
}).length;
const records = domain.concepts.map((c) => getRecord(state, c.id, c.masteryDimension || "mastery")).filter(Boolean);
const records = domain.concepts
.map((c) => getRecord(state, c.id, c.masteryDimension || "mastery"))
.filter(Boolean);
const avgScore = records.length ? records.reduce((a, r) => a + r.score, 0) / records.length : 0;
const avgConfidence = records.length ? records.reduce((a, r) => a + r.confidence, 0) / records.length : 0;
@ -137,3 +143,11 @@ export function claimReadiness(state, domain, minScore = 0.75, minConfidence = 0
avgConfidence
};
}
export function starterLearnerState(learnerId) {
return {
learner_id: learnerId,
records: [],
history: []
};
}

View File

@ -2,4 +2,5 @@ import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles.css";
createRoot(document.getElementById("root")).render(<App />);

View File

@ -1,32 +1,208 @@
:root {
--bg:#f6f8fb; --card:#ffffff; --text:#1f2430; --muted:#60697a; --border:#dbe1ea; --accent:#2d6cdf;
--bg: #f6f8fb;
--card: #ffffff;
--text: #1f2430;
--muted: #60697a;
--border: #dbe1ea;
--accent: #2d6cdf;
--soft: #eef4ff;
}
* { box-sizing:border-box; }
body { margin:0; font-family:Arial, Helvetica, sans-serif; background:var(--bg); color:var(--text); }
.page { max-width:1500px; margin:0 auto; padding:24px; }
.narrow-page { max-width:520px; }
.hero { background:var(--card); border:1px solid var(--border); border-radius:22px; padding:24px; display:flex; justify-content:space-between; gap:16px; align-items:flex-start; }
.controls { display:flex; gap:10px; align-items:flex-end; flex-wrap:wrap; }
label { display:block; font-weight:600; }
input, select { width:100%; margin-top:6px; border:1px solid var(--border); border-radius:10px; padding:10px; font:inherit; background:white; }
button { border:1px solid var(--border); background:white; border-radius:12px; padding:10px 14px; cursor:pointer; }
.primary { background:var(--accent); color:white; border-color:var(--accent); }
.card { background:var(--card); border:1px solid var(--border); border-radius:18px; padding:18px; }
.narrow { margin-top:60px; }
.layout { display:grid; gap:16px; }
.twocol { grid-template-columns:1fr 1fr; }
.prebox { background:#f7f8fa; border:1px solid var(--border); border-radius:12px; padding:12px; overflow:auto; max-height:420px; }
.muted { color:var(--muted); }
.error { color:#b42318; margin-top:10px; }
.frame-meta { display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-bottom:16px; }
.bars { display:grid; gap:12px; }
.bar-row { display:grid; grid-template-columns:140px 1fr 70px; gap:10px; align-items:center; }
.bar-track { height:22px; border-radius:999px; background:#eef2f7; overflow:hidden; border:1px solid var(--border); }
.bar-fill { height:100%; background:var(--accent); border-radius:999px; }
.bar-label, .bar-value { font-size:14px; }
@media (max-width:1100px) {
.hero { flex-direction:column; }
.twocol { grid-template-columns:1fr; }
.frame-meta { grid-template-columns:1fr; }
.bar-row { grid-template-columns:1fr; }
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Arial, Helvetica, sans-serif;
background: var(--bg);
color: var(--text);
}
.page {
max-width: 1500px;
margin: 0 auto;
padding: 20px;
}
.hero {
background: var(--card);
border: 1px solid var(--border);
border-radius: 22px;
padding: 24px;
display: flex;
justify-content: space-between;
gap: 16px;
}
.hero-controls {
min-width: 260px;
}
.hero-controls input {
width: 100%;
margin-top: 6px;
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px;
font: inherit;
}
.hero-controls button {
margin-top: 12px;
}
.domain-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
margin-top: 16px;
}
.domain-card {
border: 1px solid var(--border);
background: var(--card);
border-radius: 18px;
padding: 16px;
text-align: left;
cursor: pointer;
}
.domain-card.selected {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(45,108,223,0.12);
}
.domain-title {
font-size: 20px;
font-weight: 700;
}
.domain-subtitle {
margin-top: 6px;
color: var(--muted);
}
.domain-meta {
margin-top: 10px;
display: flex;
gap: 12px;
color: var(--muted);
font-size: 14px;
}
.layout {
display: grid;
grid-template-columns: 1fr 1.25fr 0.95fr;
gap: 16px;
margin-top: 16px;
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 20px;
padding: 18px;
}
.muted {
color: var(--muted);
}
.steps-stack {
display: grid;
gap: 14px;
}
.step-card {
border: 1px solid var(--border);
border-radius: 16px;
padding: 14px;
background: #fcfdff;
}
.step-header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: start;
}
.reward-pill {
background: var(--soft);
border: 1px solid var(--border);
border-radius: 999px;
padding: 8px 10px;
font-size: 12px;
}
button {
border: 1px solid var(--border);
background: white;
border-radius: 12px;
padding: 10px 14px;
cursor: pointer;
}
.primary {
margin-top: 10px;
background: var(--accent);
color: white;
border: none;
}
.map-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.map-node {
border: 1px solid var(--border);
border-radius: 16px;
padding: 14px;
}
.map-node.mastered { background: #eef9f0; }
.map-node.active, .map-node.available { background: #eef4ff; }
.map-node.locked { background: #f6f7fa; }
.node-label {
font-weight: 700;
}
.node-status {
margin-top: 6px;
color: var(--muted);
text-transform: capitalize;
}
.progress-wrap {
margin-bottom: 14px;
}
.progress-bar {
width: 100%;
height: 12px;
border-radius: 999px;
background: #e9edf4;
overflow: hidden;
margin: 8px 0;
}
.progress-fill {
height: 100%;
background: var(--accent);
}
.reward-banner {
background: #fff7dd;
border: 1px solid #ecdca2;
border-radius: 14px;
padding: 12px;
margin-bottom: 12px;
font-weight: 700;
}
.readiness-box {
border: 1px solid var(--border);
background: #fbfcfe;
border-radius: 14px;
padding: 12px;
}
.readiness-box.ready {
background: #eef9f0;
}
.compliance-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.flag-row {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.flag {
border: 1px solid var(--border);
background: #f4f7fc;
border-radius: 999px;
padding: 6px 10px;
font-size: 12px;
}
details summary {
cursor: pointer;
color: var(--accent);
}
@media (max-width: 1100px) {
.layout { grid-template-columns: 1fr; }
.domain-grid { grid-template-columns: 1fr; }
.hero { flex-direction: column; }
}