From 03ceea79fefe89ce0e9143599331f3e80277f57f Mon Sep 17 00:00:00 2001 From: welsberr Date: Sat, 14 Mar 2026 13:29:56 -0400 Subject: [PATCH] Apply ZIP update: 215-didactopus-live-learner-ui-prototype.zip [2026-03-14T13:20:39] --- pyproject.toml | 12 +- src/didactopus/progression_engine.py | 29 +-- tests/test_ui_files.py | 4 +- webui/index.html | 6 +- webui/package.json | 16 +- webui/src/App.jsx | 360 +++++++++++++++------------ webui/src/engine.js | 24 +- webui/src/main.jsx | 1 + webui/src/styles.css | 234 ++++++++++++++--- 9 files changed, 452 insertions(+), 234 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 53c7bda..1fcb487 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/src/didactopus/progression_engine.py b/src/didactopus/progression_engine.py index 9dac940..07326cf 100644 --- a/src/didactopus/progression_engine.py +++ b/src/didactopus/progression_engine.py @@ -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 diff --git a/tests/test_ui_files.py b/tests/test_ui_files.py index 7a06f82..ceba475 100644 --- a/tests/test_ui_files.py +++ b/tests/test_ui_files.py @@ -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() diff --git a/webui/index.html b/webui/index.html index 7b86087..65b360d 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3,8 +3,10 @@ - Didactopus Learning Animation + Didactopus Live Learner Prototype -
+ +
+ diff --git a/webui/package.json b/webui/package.json index d852efb..d4fef50 100644 --- a/webui/package.json +++ b/webui/package.json @@ -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" + } } diff --git a/webui/src/App.jsx b/webui/src/App.jsx index 19ba5d8..cbf6835 100644 --- a/webui/src/App.jsx +++ b/webui/src/App.jsx @@ -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 ( -
-
-

Didactopus login

- - - - {error ?
{error}
: null} -
+ + ); +} + +function OnboardingPanel({ domain, learnerName }) { + return ( +
+

First-session onboarding

+

{domain.onboarding.headline}

+

{domain.onboarding.body}

+

Learner: {learnerName || "Unnamed learner"}

+
    + {domain.onboarding.checklist.map((item, idx) =>
  • {item}
  • )} +
+
+ ); +} + +function NextStepCard({ step, onSimulate }) { + return ( +
+
+
+

{step.title}

+
{step.minutes} minutes
+
+
{step.reward}
+
+

{step.reason}

+
+ Why this is recommended +
    + {step.why.map((item, idx) =>
  • {item}
  • )} +
+
+
); } -function AnimationBars({ frame, concepts }) { - if (!frame) return null; +function MasteryMap({ nodes }) { return ( -
- {concepts.map((concept) => { - const value = frame.scores?.[concept] ?? 0; - return ( -
-
{concept}
-
-
-
-
{value.toFixed(2)}
+
+

Visible mastery map

+
+ {nodes.map((node) => ( +
+
{node.label}
+
{node.status}
- ); - })} -
+ ))} +
+ + ); +} + +function ProgressPanel({ progress, readiness }) { + return ( +
+

Progress

+
+
Mastery progress
+
+
{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)}
+
+
+ ); +} + +function MilestonePanel({ milestones, lastReward }) { + return ( +
+

Milestones and rewards

+ {lastReward ?
{lastReward}
: null} +
    + {milestones.map((m, idx) =>
  • {m}
  • )} +
+
+ ); +} + +function CompliancePanel({ compliance }) { + return ( +
+

Source attribution and compliance

+
+
Sources
{compliance.sources}
+
Attribution
{compliance.attributionRequired ? "required" : "not required"}
+
Share-alike
{compliance.shareAlikeRequired ? "yes" : "no"}
+
Noncommercial
{compliance.noncommercialOnly ? "yes" : "no"}
+
+
+ {compliance.flags.length ? compliance.flags.map((f) => {f}) : No extra flags} +
+

+ Provenance-sensitive packs should remain inspectable so that learners and maintainers do not have to guess at reuse constraints. +

+
+ ); +} + +function EvidenceLog({ history }) { + return ( +
+

Evidence log

+ {history.length === 0 ? ( +
No evidence recorded yet.
+ ) : ( +
    + {history.slice().reverse().map((item, idx) => ( +
  • + {item.concept_id} · score {item.score.toFixed(2)} · confidence hint {item.confidence_hint.toFixed(2)} +
  • + ))} +
+ )} +
); } 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 ; - return (
-

Didactopus learning animation

-

Replay mastery changes for human or AI learners over time.

-
{message}
+

Didactopus learner prototype

+

+ Pick a topic, get a clear onboarding path, see live progress, and inspect exactly why the system recommends each next step. +

-
-
-
-
-

Current frame

-
-
Frame: {frameIndex + 1} / {animation?.frames?.length || 0}
-
Event: {currentFrame?.event_kind || "-"}
-
Concept: {currentFrame?.concept_id || "-"}
-
Timestamp: {currentFrame?.timestamp || "-"}
-
- -
+
+ {domains.map((d) => ( + + ))} +
-
-

Animation data

-
{JSON.stringify(animation, null, 2)}
-
+
+
+ + + +
+ +
+
+

What should I do next?

+ {recs.length === 0 ? ( +
No immediate recommendation available. You may have completed the current progression or need a new pack.
+ ) : ( +
+ {recs.map((step) => )} +
+ )} +
+
+ +
+ + + +
); diff --git a/webui/src/engine.js b/webui/src/engine.js index 1cb1fe2..bc743a7 100644 --- a/webui/src/engine.js +++ b/webui/src/engine.js @@ -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: [] + }; +} diff --git a/webui/src/main.jsx b/webui/src/main.jsx index 7352818..8ad26cf 100644 --- a/webui/src/main.jsx +++ b/webui/src/main.jsx @@ -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(); diff --git a/webui/src/styles.css b/webui/src/styles.css index 8743edb..d8cfd85 100644 --- a/webui/src/styles.css +++ b/webui/src/styles.css @@ -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; } }