diff --git a/pyproject.toml b/pyproject.toml
index 09093c2..b51b837 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -10,14 +10,11 @@ dependencies = [
"pydantic>=2.7",
"pyyaml>=6.0",
"fastapi>=0.115",
- "uvicorn>=0.30",
- "sqlalchemy>=2.0",
- "passlib[bcrypt]>=1.7"
+ "uvicorn>=0.30"
]
[project.scripts]
didactopus-api = "didactopus.api:main"
-didactopus-seed-db = "didactopus.seed:main"
[tool.setuptools.packages.find]
where = ["src"]
diff --git a/src/didactopus/api.py b/src/didactopus/api.py
index b1de0a3..f0b3f59 100644
--- a/src/didactopus/api.py
+++ b/src/didactopus/api.py
@@ -1,19 +1,14 @@
from __future__ import annotations
-from fastapi import FastAPI, HTTPException, Header, Depends, BackgroundTasks
+from pathlib import Path
+from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
import uvicorn
-from .config import load_settings
-from .db import Base, engine
-from .models import LoginRequest, LoginResponse, CreateLearnerRequest, LearnerState, EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus
-from .repository import (
- authenticate_user, get_user_by_token, list_packs, get_pack, create_learner,
- learner_owned_by_user, load_learner_state, save_learner_state,
- create_evaluator_job, get_evaluator_job, update_evaluator_job
-)
+from .models import LearnerState, EvidenceEvent
+from .storage import FileStorage
from .engine import apply_evidence, recommend_next
-settings = load_settings()
-Base.metadata.create_all(bind=engine)
+BASE_DIR = Path(__file__).resolve().parents[2] / "data"
+storage = FileStorage(BASE_DIR)
app = FastAPI(title="Didactopus API Prototype")
app.add_middleware(
@@ -24,112 +19,44 @@ app.add_middleware(
allow_headers=["*"],
)
-def current_user(authorization: str = Header(default="")):
- token = authorization.removeprefix("Bearer ").strip()
- user = get_user_by_token(token) if token else None
- if user is None:
- raise HTTPException(status_code=401, detail="Unauthorized")
- return user
-
-def ensure_learner_access(user, learner_id: str):
- if not learner_owned_by_user(user.id, learner_id):
- raise HTTPException(status_code=403, detail="Learner not accessible by this user")
-
-def simulate_evaluator_job(job_id: int):
- job = get_evaluator_job(job_id)
- if job is None:
- return
- update_evaluator_job(job_id, "running")
- score = 0.78 if len(job.submitted_text.strip()) > 20 else 0.62
- confidence_hint = 0.72 if len(job.submitted_text.strip()) > 20 else 0.45
- notes = "Prototype evaluator: longer responses scored somewhat higher."
- update_evaluator_job(job_id, "completed", score=score, confidence_hint=confidence_hint, notes=notes)
- state = load_learner_state(job.learner_id)
- state = apply_evidence(state, EvidenceEvent(
- concept_id=job.concept_id,
- dimension="mastery",
- score=score,
- confidence_hint=confidence_hint,
- timestamp="2026-03-13T12:00:00+00:00",
- kind="review",
- source_id=f"evaluator-job-{job_id}",
- ))
- save_learner_state(state)
-
-@app.post("/api/login", response_model=LoginResponse)
-def login(payload: LoginRequest):
- user = authenticate_user(payload.username, payload.password)
- if user is None:
- raise HTTPException(status_code=401, detail="Invalid credentials")
- return LoginResponse(token=user.token, username=user.username)
-
@app.get("/api/packs")
-def api_list_packs(user = Depends(current_user)):
- return [p.model_dump() for p in list_packs()]
+def list_packs():
+ return [p.model_dump() for p in storage.list_packs()]
@app.get("/api/packs/{pack_id}")
-def api_get_pack(pack_id: str, user = Depends(current_user)):
- pack = get_pack(pack_id)
+def get_pack(pack_id: str):
+ pack = storage.get_pack(pack_id)
if pack is None:
raise HTTPException(status_code=404, detail="Pack not found")
return pack.model_dump()
-@app.post("/api/learners")
-def api_create_learner(payload: CreateLearnerRequest, user = Depends(current_user)):
- create_learner(user.id, payload.learner_id, payload.display_name)
- return {"ok": True, "learner_id": payload.learner_id}
-
@app.get("/api/learners/{learner_id}/state")
-def api_get_learner_state(learner_id: str, user = Depends(current_user)):
- ensure_learner_access(user, learner_id)
- return load_learner_state(learner_id).model_dump()
+def get_learner_state(learner_id: str):
+ return storage.get_learner_state(learner_id).model_dump()
@app.put("/api/learners/{learner_id}/state")
-def api_put_learner_state(learner_id: str, state: LearnerState, user = Depends(current_user)):
- ensure_learner_access(user, learner_id)
+def put_learner_state(learner_id: str, state: LearnerState):
if learner_id != state.learner_id:
raise HTTPException(status_code=400, detail="Learner ID mismatch")
- return save_learner_state(state).model_dump()
+ return storage.save_learner_state(state).model_dump()
@app.post("/api/learners/{learner_id}/evidence")
-def api_post_evidence(learner_id: str, event: EvidenceEvent, user = Depends(current_user)):
- ensure_learner_access(user, learner_id)
- state = load_learner_state(learner_id)
+def post_evidence(learner_id: str, event: EvidenceEvent):
+ state = storage.get_learner_state(learner_id)
state = apply_evidence(state, event)
- save_learner_state(state)
+ storage.save_learner_state(state)
return state.model_dump()
@app.get("/api/learners/{learner_id}/recommendations/{pack_id}")
-def api_get_recommendations(learner_id: str, pack_id: str, user = Depends(current_user)):
- ensure_learner_access(user, learner_id)
- state = load_learner_state(learner_id)
- pack = get_pack(pack_id)
+def get_recommendations(learner_id: str, pack_id: str):
+ state = storage.get_learner_state(learner_id)
+ pack = storage.get_pack(pack_id)
if pack is None:
raise HTTPException(status_code=404, detail="Pack not found")
return {"cards": recommend_next(state, pack)}
-@app.post("/api/learners/{learner_id}/evaluator-jobs", response_model=EvaluatorJobStatus)
-def api_submit_evaluator_job(learner_id: str, payload: EvaluatorSubmission, background_tasks: BackgroundTasks, user = Depends(current_user)):
- ensure_learner_access(user, learner_id)
- job_id = create_evaluator_job(learner_id, payload.pack_id, payload.concept_id, payload.submitted_text)
- background_tasks.add_task(simulate_evaluator_job, job_id)
- return EvaluatorJobStatus(job_id=job_id, status="queued")
-
-@app.get("/api/evaluator-jobs/{job_id}", response_model=EvaluatorJobStatus)
-def api_get_evaluator_job(job_id: int, user = Depends(current_user)):
- job = get_evaluator_job(job_id)
- if job is None:
- raise HTTPException(status_code=404, detail="Job not found")
- return EvaluatorJobStatus(
- job_id=job.id,
- status=job.status,
- result_score=job.result_score,
- result_confidence_hint=job.result_confidence_hint,
- result_notes=job.result_notes,
- )
-
def main():
- uvicorn.run(app, host=settings.host, port=settings.port)
+ uvicorn.run(app, host="127.0.0.1", port=8011)
if __name__ == "__main__":
main()
diff --git a/src/didactopus/models.py b/src/didactopus/models.py
index b48284f..2ea8228 100644
--- a/src/didactopus/models.py
+++ b/src/didactopus/models.py
@@ -48,28 +48,3 @@ class LearnerState(BaseModel):
learner_id: str
records: list[MasteryRecord] = Field(default_factory=list)
history: list[EvidenceEvent] = Field(default_factory=list)
-
-class LoginRequest(BaseModel):
- username: str
- password: str
-
-class LoginResponse(BaseModel):
- token: str
- username: str
-
-class CreateLearnerRequest(BaseModel):
- learner_id: str
- display_name: str = ""
-
-class EvaluatorSubmission(BaseModel):
- pack_id: str
- concept_id: str
- submitted_text: str
- kind: str = "checkpoint"
-
-class EvaluatorJobStatus(BaseModel):
- job_id: int
- status: str
- result_score: float | None = None
- result_confidence_hint: float | None = None
- result_notes: str = ""
diff --git a/tests/test_pack_export.py b/tests/test_pack_export.py
index 85670f5..0d0704f 100644
--- a/tests/test_pack_export.py
+++ b/tests/test_pack_export.py
@@ -2,16 +2,9 @@ from pathlib import Path
from didactopus.pack_to_frontend import convert_pack
def test_convert_pack(tmp_path: Path):
- (tmp_path / "pack.yaml").write_text(
- "name: p1\ndisplay_name: Pack 1\ndescription: Demo pack\n", encoding="utf-8"
- )
- (tmp_path / "concepts.yaml").write_text(
- "concepts:\n - id: c1\n title: Concept 1\n prerequisites: []\n", encoding="utf-8"
- )
- (tmp_path / "pack_compliance_manifest.json").write_text(
- '{"derived_from_sources":["s1"],"attribution_required":true,"share_alike_required":false,"noncommercial_only":false,"restrictive_flags":[]}',
- encoding="utf-8"
- )
+ (tmp_path / "pack.yaml").write_text("name: p1\ndisplay_name: Pack 1\ndescription: Demo pack\n", encoding="utf-8")
+ (tmp_path / "concepts.yaml").write_text("concepts:\n - id: c1\n title: Concept 1\n prerequisites: []\n", encoding="utf-8")
+ (tmp_path / "pack_compliance_manifest.json").write_text('{"derived_from_sources":["s1"],"attribution_required":true,"share_alike_required":false,"noncommercial_only":false,"restrictive_flags":[]}', encoding="utf-8")
payload = convert_pack(tmp_path)
assert payload["id"] == "p1"
assert payload["concepts"][0]["id"] == "c1"
diff --git a/webui/index.html b/webui/index.html
index 0869131..0ef5c68 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -3,7 +3,7 @@
- Didactopus Auth API UI
+ Didactopus API UI
diff --git a/webui/package.json b/webui/package.json
index 685fd94..7093a6c 100644
--- a/webui/package.json
+++ b/webui/package.json
@@ -1,5 +1,5 @@
{
- "name": "didactopus-auth-api-ui",
+ "name": "didactopus-api-ui",
"private": true,
"version": "0.1.0",
"type": "module",
diff --git a/webui/src/App.jsx b/webui/src/App.jsx
index 5bac568..769e4d0 100644
--- a/webui/src/App.jsx
+++ b/webui/src/App.jsx
@@ -1,38 +1,16 @@
import React, { useEffect, useMemo, useState } from "react";
-import { login, fetchPacks, createLearner, fetchLearnerState, fetchRecommendations, postEvidence, submitEvaluatorJob, fetchEvaluatorJob } from "./api";
-import { buildMasteryMap, progressPercent } from "./localEngine";
-
-function LoginPanel({ onLogin }) {
- const [username, setUsername] = useState("wesley");
- const [password, setPassword] = useState("demo-pass");
- const [error, setError] = useState("");
-
- async function handleLogin() {
- try {
- setError("");
- const result = await onLogin(username, password);
- return result;
- } catch (e) {
- setError("Login failed");
- }
- }
-
- return (
-
- );
-}
+import { fetchPacks, fetchLearnerState, fetchRecommendations, postEvidence } from "./api";
+import { buildMasteryMap, progressPercent, milestoneMessages, claimReadiness } from "./localEngine";
function DomainCard({ domain, selected, onSelect }) {
return (
);
}
@@ -52,51 +30,49 @@ function NextStepCard({ step, onSimulate }) {
Why this is recommended
{step.why.map((item, idx) => - {item}
)}
-
-
-
+
);
}
export default function App() {
- const [token, setToken] = useState("");
- const [username, setUsername] = useState("");
const [packs, setPacks] = useState([]);
const [selectedDomainId, setSelectedDomainId] = useState("");
- const [learnerId, setLearnerId] = useState("wesley-learner");
+ const [learnerName, setLearnerName] = useState("Wesley");
const [learnerState, setLearnerState] = useState(null);
const [cards, setCards] = useState([]);
- const [jobStatus, setJobStatus] = useState(null);
+ const [lastReward, setLastReward] = useState("");
+ const learnerId = "wesley-demo";
- async function handleLogin(user, pass) {
- const auth = await login(user, pass);
- setToken(auth.token);
- setUsername(auth.username);
- await createLearner(auth.token, learnerId, learnerId);
- const loadedPacks = await fetchPacks(auth.token);
- setPacks(loadedPacks);
- setSelectedDomainId(loadedPacks[0]?.id || "");
- const state = await fetchLearnerState(auth.token, learnerId);
- setLearnerState(state);
- return auth;
- }
+ useEffect(() => {
+ async function load() {
+ const loadedPacks = await fetchPacks();
+ setPacks(loadedPacks);
+ const first = loadedPacks[0]?.id || "";
+ setSelectedDomainId(first);
+ const state = await fetchLearnerState(learnerId);
+ setLearnerState(state);
+ }
+ load();
+ }, []);
useEffect(() => {
async function loadCards() {
- if (!token || !selectedDomainId) return;
- const data = await fetchRecommendations(token, learnerId, selectedDomainId);
+ if (!selectedDomainId) return;
+ const data = await fetchRecommendations(learnerId, selectedDomainId);
setCards(data.cards || []);
}
- loadCards();
- }, [token, learnerId, selectedDomainId, learnerState]);
+ if (selectedDomainId) loadCards();
+ }, [selectedDomainId, learnerState]);
const domain = useMemo(() => packs.find((d) => d.id === selectedDomainId) || null, [packs, selectedDomainId]);
const masteryMap = useMemo(() => domain && learnerState ? buildMasteryMap(learnerState, domain) : [], [learnerState, domain]);
const progress = useMemo(() => domain && learnerState ? progressPercent(learnerState, domain) : 0, [learnerState, domain]);
+ const milestones = useMemo(() => domain && learnerState ? milestoneMessages(learnerState, domain) : [], [learnerState, domain]);
+ const readiness = useMemo(() => domain && learnerState ? claimReadiness(learnerState, domain) : {ready:false, mastered:0, avgScore:0, avgConfidence:0}, [learnerState, domain]);
async function simulateStep(step) {
- const nextState = await postEvidence(token, learnerId, {
+ const nextState = await postEvidence(learnerId, {
concept_id: step.conceptId,
dimension: "mastery",
score: step.scoreHint,
@@ -106,32 +82,11 @@ export default function App() {
source_id: `ui-${step.id}`
});
setLearnerState(nextState);
- }
-
- async function submitDemoEvaluator() {
- if (!domain) return;
- const firstConcept = domain.concepts[0]?.id;
- const job = await submitEvaluatorJob(token, learnerId, {
- pack_id: domain.id,
- concept_id: firstConcept,
- submitted_text: "This is a moderately detailed learner response intended to trigger a somewhat better prototype evaluator score.",
- kind: "checkpoint"
- });
- setJobStatus(job);
- setTimeout(async () => {
- const refreshed = await fetchEvaluatorJob(token, job.job_id);
- setJobStatus(refreshed);
- const state = await fetchLearnerState(token, learnerId);
- setLearnerState(state);
- }, 1200);
- }
-
- if (!token) {
- return
;
+ setLastReward(step.reward);
}
if (!domain || !learnerState) {
- return Loading authenticated learner view...
;
+ return ;
}
return (
@@ -139,12 +94,13 @@ export default function App() {
Didactopus learner prototype
-
Authenticated multi-user scaffold with DB-backed state and async evaluator jobs.
-
Signed in as {username}
+
Backend-driven pack registry, learner-state persistence, and evaluator-style evidence ingestion.
-
-
+
@@ -155,9 +111,10 @@ export default function App() {
- Onboarding
+ First-session onboarding
{domain.onboarding.headline}
{domain.onboarding.body}
+ Learner: {learnerName}
{(domain.onboarding.checklist || []).map((item, idx) => - {item}
)}
@@ -174,16 +131,13 @@ export default function App() {
- Async evaluator job
- {jobStatus ? (
-
-
Status: {jobStatus.status}
-
Score: {jobStatus.result_score ?? "-"}
-
Confidence hint: {jobStatus.result_confidence_hint ?? "-"}
-
{jobStatus.result_notes || ""}
-
- ) : (
- No evaluator job submitted yet.
+ Evidence log
+ {learnerState.history.length === 0 ? No evidence recorded yet.
: (
+
+ {learnerState.history.slice().reverse().map((item, idx) => (
+ - {item.concept_id} · score {item.score.toFixed(2)} · confidence hint {item.confidence_hint.toFixed(2)}
+ ))}
+
)}
@@ -207,27 +161,31 @@ export default function App() {
{progress}%
+
+
{readiness.ready ? "Usable expertise threshold met" : "Still building toward usable expertise"}
+
Mastered concepts: {readiness.mastered}
+
Average score: {readiness.avgScore.toFixed(2)}
+
Average confidence: {readiness.avgConfidence.toFixed(2)}
+
- Evidence log
- {learnerState.history.length === 0 ? No evidence recorded yet.
: (
-
- {learnerState.history.slice().reverse().map((item, idx) => (
- - {item.concept_id} · score {item.score.toFixed(2)} · hint {item.confidence_hint.toFixed(2)} · {item.kind}
- ))}
-
- )}
+ Milestones and rewards
+ {lastReward ? {lastReward}
: null}
+ {milestones.map((m, idx) => - {m}
)}
- Compliance
+ Source attribution and compliance
Sources
{domain.compliance.sources}
Attribution
{domain.compliance.attributionRequired ? "required" : "not required"}
Share-alike
{domain.compliance.shareAlikeRequired ? "yes" : "no"}
Noncommercial
{domain.compliance.noncommercialOnly ? "yes" : "no"}
+
+ {domain.compliance.flags.length ? domain.compliance.flags.map((f) => {f}) : No extra flags}
+
diff --git a/webui/src/api.js b/webui/src/api.js
index 7d68e72..0091ccf 100644
--- a/webui/src/api.js
+++ b/webui/src/api.js
@@ -1,62 +1,25 @@
const API = "http://127.0.0.1:8011/api";
-export async function login(username, password) {
- const res = await fetch(`${API}/login`, {
- method: "POST",
- headers: {"Content-Type": "application/json"},
- body: JSON.stringify({ username, password })
- });
- if (!res.ok) throw new Error("Login failed");
+export async function fetchPacks() {
+ const res = await fetch(`${API}/packs`);
return await res.json();
}
-function authHeaders(token) {
- return { "Content-Type": "application/json", "Authorization": `Bearer ${token}` };
-}
-
-export async function fetchPacks(token) {
- const res = await fetch(`${API}/packs`, { headers: authHeaders(token) });
+export async function fetchLearnerState(learnerId) {
+ const res = await fetch(`${API}/learners/${learnerId}/state`);
return await res.json();
}
-export async function createLearner(token, learnerId, displayName) {
- const res = await fetch(`${API}/learners`, {
- method: "POST",
- headers: authHeaders(token),
- body: JSON.stringify({ learner_id: learnerId, display_name: displayName })
- });
- return await res.json();
-}
-
-export async function fetchLearnerState(token, learnerId) {
- const res = await fetch(`${API}/learners/${learnerId}/state`, { headers: authHeaders(token) });
- return await res.json();
-}
-
-export async function postEvidence(token, learnerId, event) {
+export async function postEvidence(learnerId, event) {
const res = await fetch(`${API}/learners/${learnerId}/evidence`, {
method: "POST",
- headers: authHeaders(token),
+ headers: {"Content-Type": "application/json"},
body: JSON.stringify(event)
});
return await res.json();
}
-export async function fetchRecommendations(token, learnerId, packId) {
- const res = await fetch(`${API}/learners/${learnerId}/recommendations/${packId}`, { headers: authHeaders(token) });
- return await res.json();
-}
-
-export async function submitEvaluatorJob(token, learnerId, payload) {
- const res = await fetch(`${API}/learners/${learnerId}/evaluator-jobs`, {
- method: "POST",
- headers: authHeaders(token),
- body: JSON.stringify(payload)
- });
- return await res.json();
-}
-
-export async function fetchEvaluatorJob(token, jobId) {
- const res = await fetch(`${API}/evaluator-jobs/${jobId}`, { headers: authHeaders(token) });
+export async function fetchRecommendations(learnerId, packId) {
+ const res = await fetch(`${API}/learners/${learnerId}/recommendations/${packId}`);
return await res.json();
}
diff --git a/webui/src/localEngine.js b/webui/src/localEngine.js
index 6dcb20e..f331af6 100644
--- a/webui/src/localEngine.js
+++ b/webui/src/localEngine.js
@@ -22,3 +22,27 @@ export function progressPercent(state, domain) {
const mastered = domain.concepts.filter((c) => conceptStatus(state, c) === "mastered").length;
return Math.round((mastered / total) * 100);
}
+
+export function milestoneMessages(state, domain) {
+ const msgs = [];
+ for (const concept of domain.concepts) {
+ if (conceptStatus(state, concept) === "mastered") msgs.push(`${concept.title} mastered`);
+ }
+ if (msgs.length === 0) msgs.push("Complete your first guided exercise to earn a visible mastery marker");
+ return msgs;
+}
+
+export function claimReadiness(state, domain, minScore = 0.75, minConfidence = 0.60) {
+ const records = domain.concepts
+ .map((c) => getRecord(state, c.id, c.masteryDimension || "mastery"))
+ .filter(Boolean);
+ const mastered = records.filter((r) => r.score >= minScore && r.confidence >= minConfidence).length;
+ 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;
+ return {
+ ready: mastered >= Math.max(1, domain.concepts.length - 1) && avgScore >= minScore && avgConfidence >= minConfidence,
+ mastered,
+ avgScore,
+ avgConfidence
+ };
+}
diff --git a/webui/src/styles.css b/webui/src/styles.css
index 1bde8d8..119d2a9 100644
--- a/webui/src/styles.css
+++ b/webui/src/styles.css
@@ -21,18 +21,13 @@ body { margin: 0; font-family: Arial, Helvetica, sans-serif; background: var(--b
.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; }
-.narrow { max-width: 420px; margin: 60px auto; }
-label { display: block; font-weight: 600; margin-bottom: 12px; }
-input { width: 100%; margin-top: 6px; border: 1px solid var(--border); border-radius: 10px; padding: 10px; font: inherit; }
.muted { color: var(--muted); }
-.error { color: #b42318; margin-top: 10px; }
.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 { background: var(--accent); color: white; border: none; }
-.button-row { display: flex; gap: 10px; }
+.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; }
@@ -43,7 +38,12 @@ button { border: 1px solid var(--border); background: white; border-radius: 12px
.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; }