Apply ZIP update: 230-didactopus-pack-persistence-update.zip [2026-03-14T13:20:51]

This commit is contained in:
welsberr 2026-03-14 13:29:56 -04:00
parent 3405d45e31
commit b722c379dd
9 changed files with 263 additions and 169 deletions

View File

@ -9,7 +9,7 @@ requires-python = ">=3.10"
dependencies = ["pydantic>=2.7", "pyyaml>=6.0"] dependencies = ["pydantic>=2.7", "pyyaml>=6.0"]
[project.scripts] [project.scripts]
didactopus-demo-run = "didactopus.demo_run:main" didactopus-pack-to-frontend = "didactopus.pack_to_frontend:main"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]

View File

@ -2,9 +2,16 @@ from pathlib import Path
from didactopus.pack_to_frontend import convert_pack from didactopus.pack_to_frontend import convert_pack
def test_convert_pack(tmp_path: Path): 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 / "pack.yaml").write_text(
(tmp_path / "concepts.yaml").write_text("concepts:\n - id: c1\n title: Concept 1\n prerequisites: []\n", encoding="utf-8") "name: p1\ndisplay_name: Pack 1\ndescription: Demo pack\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 / "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) payload = convert_pack(tmp_path)
assert payload["id"] == "p1" assert payload["id"] == "p1"
assert payload["concepts"][0]["id"] == "c1" assert payload["concepts"][0]["id"] == "c1"

View File

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

View File

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

View File

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

View File

@ -1,145 +1,206 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { login, refresh, fetchPacks, fetchLearnerState, putLearnerState, fetchGraphAnimation, createRenderBundle } from "./api"; import { applyEvidence, buildMasteryMap, claimReadiness, milestoneMessages, progressPercent, recommendNext } from "./engine";
import { loadAuth, saveAuth, clearAuth } from "./authStore"; import { loadLearnerState, saveLearnerState, resetLearnerState } from "./storage";
function LoginView({ onAuth }) { const PACKS = ["/packs/bayes-pack.json", "/packs/stats-pack.json"];
const [username, setUsername] = useState("wesley");
const [password, setPassword] = useState("demo-pass"); function DomainCard({ domain, selected, onSelect }) {
const [error, setError] = useState("");
async function doLogin() {
try {
const result = await login(username, password);
saveAuth(result);
onAuth(result);
} catch { setError("Login failed"); }
}
return ( return (
<div className="page narrow-page"> <button className={`domain-card ${selected ? "selected" : ""}`} onClick={() => onSelect(domain.id)}>
<section className="card narrow"> <div className="domain-title">{domain.title}</div>
<h1>Didactopus login</h1> <div className="domain-subtitle">{domain.subtitle}</div>
<label>Username<input value={username} onChange={(e) => setUsername(e.target.value)} /></label> <div className="domain-meta">
<label>Password<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} /></label> <span>{domain.level}</span>
<button className="primary" onClick={doLogin}>Login</button> <span>{domain.concepts.length} concepts</span>
{error ? <div className="error">{error}</div> : null} </div>
</section> </button>
);
}
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> </div>
); );
} }
export default function App() { export default function App() {
const [auth, setAuth] = useState(loadAuth());
const [packs, setPacks] = useState([]); const [packs, setPacks] = useState([]);
const [learnerId] = useState("wesley-learner"); const [selectedDomainId, setSelectedDomainId] = useState("");
const [packId, setPackId] = useState(""); const [learnerName, setLearnerName] = useState("Wesley");
const [bundle, setBundle] = useState(null); const [domainStates, setDomainStates] = useState({});
const [format, setFormat] = useState("gif"); const [lastReward, setLastReward] = useState("");
const [fps, setFps] = useState(2);
const [message, setMessage] = 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;
}
}
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);
}
}
useEffect(() => { useEffect(() => {
if (!auth) return; Promise.all(PACKS.map((u) => fetch(u).then((r) => r.json()))).then((loaded) => {
async function load() { setPacks(loaded);
const p = await guarded((token) => fetchPacks(token)); setSelectedDomainId(loaded[0]?.id || "");
setPacks(p); const states = {};
setPackId(p[0]?.id || ""); for (const pack of loaded) {
states[pack.id] = loadLearnerState(pack.id);
} }
load(); setDomainStates(states);
}, [auth]); });
}, []);
async function generateDemo() { const domain = useMemo(() => packs.find((d) => d.id === selectedDomainId) || null, [packs, selectedDomainId]);
let state = await guarded((token) => fetchLearnerState(token, learnerId)); const learnerState = domain ? (domainStates[domain.id] || loadLearnerState(domain.id)) : null;
const base = Date.now();
const events = [ const masteryMap = useMemo(() => domain && learnerState ? buildMasteryMap(learnerState, domain) : [], [learnerState, domain]);
["intro", 0.30, "exercise", 0], const progress = useMemo(() => domain && learnerState ? progressPercent(learnerState, domain) : 0, [learnerState, domain]);
["intro", 0.78, "review", 1000], const recs = useMemo(() => domain && learnerState ? recommendNext(learnerState, domain) : [], [learnerState, domain]);
["second", 0.42, "exercise", 2000], const milestones = useMemo(() => domain && learnerState ? milestoneMessages(learnerState, domain) : [], [learnerState, domain]);
["second", 0.72, "review", 3000], const readiness = useMemo(() => domain && learnerState ? claimReadiness(learnerState, domain) : {ready:false, mastered:0, avgScore:0, avgConfidence:0}, [learnerState, domain]);
["third", 0.25, "exercise", 4000],
["branch", 0.60, "exercise", 5000], function updateState(domainId, nextState) {
]; saveLearnerState(domainId, nextState);
const latest = {} setDomainStates((prev) => ({ ...prev, [domainId]: nextState }));
for (const [cid, score, kind, offset] of events) {
const ts = new Date(base + offset).toISOString();
state.history.push({ concept_id: cid, dimension: "mastery", score, confidence_hint: 0.6, timestamp: ts, kind, source_id: `demo-${cid}-${offset}` });
latest[cid] = { concept_id: cid, dimension: "mastery", score, confidence: Math.min(0.9, score), evidence_count: (latest[cid]?.evidence_count || 0) + 1, last_updated: ts };
}
state.records = Object.values(latest);
await guarded((token) => putLearnerState(token, learnerId, state));
setMessage("Demo state generated.");
} }
async function renderNow() { function simulateStep(step) {
const result = await guarded((token) => createRenderBundle(token, learnerId, packId, { learner_id: learnerId, pack_id: packId, format, fps, theme: "default" })); if (!domain || !learnerState) return;
setBundle(result); const timestamp = new Date().toISOString();
setMessage("Render bundle created."); 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}`
});
updateState(domain.id, updated);
setLastReward(step.reward);
} }
if (!auth) return <LoginView onAuth={setAuth} />; function resetSelectedDomain() {
if (!domain) return;
resetLearnerState(domain.id);
updateState(domain.id, loadLearnerState(domain.id));
setLastReward("");
}
if (!domain || !learnerState) {
return <div className="page"><div className="card">Loading packs...</div></div>;
}
return ( return (
<div className="page"> <div className="page">
<header className="hero"> <header className="hero">
<div> <div>
<h1>Didactopus media rendering pipeline</h1> <h1>Didactopus learner prototype</h1>
<p>Create GIF/MP4-ready render bundles from animated learning graphs.</p> <p>Real pack files, persistent learner state, and live recommendation updates.</p>
<div className="muted">{message}</div>
</div> </div>
<div className="controls"> <div className="hero-controls">
<label>Pack <label>
<select value={packId} onChange={(e) => setPackId(e.target.value)}> Learner name
{packs.map((p) => <option key={p.id} value={p.id}>{p.title}</option>)} <input value={learnerName} onChange={(e) => setLearnerName(e.target.value)} />
</select>
</label> </label>
<label>Format <button onClick={resetSelectedDomain}>Reset selected domain</button>
<select value={format} onChange={(e) => setFormat(e.target.value)}>
<option value="gif">GIF</option>
<option value="mp4">MP4</option>
</select>
</label>
<label>FPS
<input type="number" value={fps} onChange={(e) => setFps(Number(e.target.value || 2))} />
</label>
<button onClick={generateDemo}>Generate demo state</button>
<button onClick={renderNow}>Create render bundle</button>
<button onClick={() => { clearAuth(); setAuth(null); }}>Logout</button>
</div> </div>
</header> </header>
<main className="layout twocol"> <section className="domain-grid">
<section className="card"> {packs.map((d) => <DomainCard key={d.id} domain={d} selected={d.id === domain.id} onSelect={setSelectedDomainId} />)}
<h2>Bundle output</h2>
<pre className="prebox">{JSON.stringify(bundle, null, 2)}</pre>
</section> </section>
<main className="layout">
<div className="left-col">
<section className="card"> <section className="card">
<h2>What this bundle contains</h2> <h2>First-session onboarding</h2>
<div className="explain"> <h3>{domain.onboarding.headline}</h3>
<p>Each bundle includes SVG frames, a JSON manifest, and a render shell script suitable for FFmpeg-based conversion.</p> <p>{domain.onboarding.body}</p>
<p>This keeps artifact generation decoupled from the API server while still making render production straightforward.</p> <p className="muted">Learner: {learnerName || "Unnamed learner"}</p>
<ul>{domain.onboarding.checklist.map((item, idx) => <li key={idx}>{item}</li>)}</ul>
</section>
<section className="card">
<h2>Visible mastery map</h2>
<div className="map-grid">
{masteryMap.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> </section>
<section className="card">
<h2>Evidence log</h2>
{learnerState.history.length === 0 ? <div className="muted">No evidence recorded yet.</div> : (
<ul>
{learnerState.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>
</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.</div>
) : (
<div className="steps-stack">
{recs.map((step) => <NextStepCard key={step.id} step={step} onSimulate={simulateStep} />)}
</div>
)}
</section>
</div>
<div className="right-col">
<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>
<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>
<section className="card">
<h2>Source attribution and compliance</h2>
<div className="compliance-grid">
<div><strong>Sources</strong><br />{domain.compliance.sources}</div>
<div><strong>Attribution</strong><br />{domain.compliance.attributionRequired ? "required" : "not required"}</div>
<div><strong>Share-alike</strong><br />{domain.compliance.shareAlikeRequired ? "yes" : "no"}</div>
<div><strong>Noncommercial</strong><br />{domain.compliance.noncommercialOnly ? "yes" : "no"}</div>
</div>
<div className="flag-row">
{domain.compliance.flags.length ? domain.compliance.flags.map((f) => <span className="flag" key={f}>{f}</span>) : <span className="muted">No extra flags</span>}
</div>
</section>
</div>
</main> </main>
</div> </div>
); );

View File

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

View File

@ -1,24 +1,53 @@
:root { :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; } * { box-sizing: border-box; }
body { margin: 0; font-family: Arial, Helvetica, sans-serif; background: var(--bg); color: var(--text); } body { margin: 0; font-family: Arial, Helvetica, sans-serif; background: var(--bg); color: var(--text); }
.page { max-width:1500px; margin:0 auto; padding:24px; } .page { max-width: 1500px; margin: 0 auto; padding: 20px; }
.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; }
.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; } .hero-controls { min-width: 260px; }
.controls { display:flex; gap:10px; align-items:flex-end; flex-wrap:wrap; } .hero-controls input { width: 100%; margin-top: 6px; border: 1px solid var(--border); border-radius: 10px; padding: 10px; font: inherit; }
label { display:block; font-weight:600; } .hero-controls button { margin-top: 12px; }
input, select { width:100%; margin-top:6px; border:1px solid var(--border); border-radius:10px; padding:10px; font:inherit; background:white; } .domain-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin-top: 16px; }
button { border:1px solid var(--border); background:white; border-radius:12px; padding:10px 14px; cursor:pointer; } .domain-card { border: 1px solid var(--border); background: var(--card); border-radius: 18px; padding: 16px; text-align: left; cursor: pointer; }
.card { background:var(--card); border:1px solid var(--border); border-radius:18px; padding:18px; } .domain-card.selected { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(45,108,223,0.12); }
.narrow { margin-top:60px; } .domain-title { font-size: 20px; font-weight: 700; }
.layout { display:grid; gap:16px; } .domain-subtitle { margin-top: 6px; color: var(--muted); }
.twocol { grid-template-columns:1fr 1fr; } .domain-meta { margin-top: 10px; display: flex; gap: 12px; color: var(--muted); font-size: 14px; }
.prebox { background:#f7f8fa; border:1px solid var(--border); border-radius:12px; padding:12px; overflow:auto; max-height:460px; } .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); } .muted { color: var(--muted); }
.error { color:#b42318; margin-top:10px; } .steps-stack { display: grid; gap: 14px; }
.explain p { margin-top:0; } .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) { @media (max-width: 1100px) {
.layout { grid-template-columns: 1fr; }
.domain-grid { grid-template-columns: 1fr; }
.hero { flex-direction: column; } .hero { flex-direction: column; }
.twocol { grid-template-columns:1fr; }
} }