Apply ZIP update: 195-didactopus-dual-lane-policy-layer.zip [2026-03-14T13:20:27]
This commit is contained in:
parent
27bc03f77c
commit
ebd754c6ba
|
|
@ -5,10 +5,10 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||
import uvicorn
|
||||
from .config import load_settings
|
||||
from .db import Base, engine
|
||||
from .models import LoginRequest, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState, EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus, CreatePackRequest, GovernanceAction, ReviewCommentCreate, ContributionSubmissionCreate, AgentCapabilityManifest, AgentLearnerPlanRequest, AgentLearnerPlanResponse
|
||||
from .models import LoginRequest, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState, EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus, CreatePackRequest, GovernanceAction, ReviewCommentCreate, ContributionSubmissionCreate
|
||||
from .repository import (
|
||||
authenticate_user, get_user_by_id, store_refresh_token, refresh_token_active, revoke_refresh_token,
|
||||
deployment_policy_profile, list_packs_for_user, list_pack_admin_rows, get_pack, get_pack_row, get_pack_validation, get_pack_provenance,
|
||||
list_packs_for_user, list_pack_admin_rows, get_pack, get_pack_row, get_pack_validation, get_pack_provenance,
|
||||
upsert_pack, create_submission, list_submissions, get_submission_diff, get_submission_gates, list_review_tasks,
|
||||
set_pack_publication, can_publish_pack, set_governance_state, list_pack_versions, add_review_comment, list_review_comments,
|
||||
create_learner, list_learners_for_user, learner_owned_by_user, load_learner_state,
|
||||
|
|
@ -57,35 +57,6 @@ def ensure_pack_access(user, pack_id: str):
|
|||
return row
|
||||
raise HTTPException(status_code=403, detail="Pack not accessible by this user")
|
||||
|
||||
@app.get("/api/deployment-policy")
|
||||
def api_deployment_policy(user = Depends(current_user)):
|
||||
return deployment_policy_profile().model_dump()
|
||||
|
||||
@app.get("/api/agent/capabilities", response_model=AgentCapabilityManifest)
|
||||
def api_agent_capabilities(user = Depends(current_user)):
|
||||
return AgentCapabilityManifest()
|
||||
|
||||
@app.post("/api/agent/learner-plan", response_model=AgentLearnerPlanResponse)
|
||||
def api_agent_learner_plan(payload: AgentLearnerPlanRequest, user = Depends(current_user)):
|
||||
ensure_learner_access(user, payload.learner_id)
|
||||
ensure_pack_access(user, payload.pack_id)
|
||||
state = load_learner_state(payload.learner_id)
|
||||
pack = get_pack(payload.pack_id)
|
||||
if pack is None:
|
||||
raise HTTPException(status_code=404, detail="Pack not found")
|
||||
cards = recommend_next(state, pack)
|
||||
return AgentLearnerPlanResponse(
|
||||
learner_id=payload.learner_id,
|
||||
pack_id=payload.pack_id,
|
||||
next_cards=cards,
|
||||
suggested_actions=[
|
||||
"Read current learner state",
|
||||
"Select the highest-priority next card",
|
||||
"Attempt the exercise or checkpoint",
|
||||
"Post evidence and refresh recommendations",
|
||||
],
|
||||
)
|
||||
|
||||
@app.post("/api/login", response_model=TokenPair)
|
||||
def login(payload: LoginRequest):
|
||||
user = authenticate_user(payload.username, payload.password)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ class Settings(BaseModel):
|
|||
port: int = int(os.getenv("DIDACTOPUS_PORT", "8011"))
|
||||
jwt_secret: str = os.getenv("DIDACTOPUS_JWT_SECRET", "change-me")
|
||||
jwt_algorithm: str = "HS256"
|
||||
deployment_policy_profile: str = os.getenv("DIDACTOPUS_POLICY_PROFILE", "single_user")
|
||||
|
||||
def load_settings() -> Settings:
|
||||
return Settings()
|
||||
|
|
|
|||
|
|
@ -19,26 +19,6 @@ class LoginRequest(BaseModel):
|
|||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
class DeploymentPolicyProfile(BaseModel):
|
||||
profile_name: str
|
||||
default_personal_lane_enabled: bool = True
|
||||
default_community_lane_enabled: bool = True
|
||||
community_publish_requires_approval: bool = True
|
||||
personal_publish_direct: bool = True
|
||||
reviewer_assignment_required: bool = False
|
||||
description: str = ""
|
||||
|
||||
class AgentCapabilityManifest(BaseModel):
|
||||
supports_pack_listing: bool = True
|
||||
supports_pack_write_personal: bool = True
|
||||
supports_pack_submit_community: bool = True
|
||||
supports_recommendations: bool = True
|
||||
supports_learner_state_read: bool = True
|
||||
supports_learner_state_write: bool = True
|
||||
supports_evaluator_jobs: bool = True
|
||||
supports_governance_endpoints: bool = True
|
||||
supports_review_queue: bool = True
|
||||
|
||||
class PackConcept(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
|
|
@ -118,13 +98,3 @@ class EvaluatorJobStatus(BaseModel):
|
|||
result_score: float | None = None
|
||||
result_confidence_hint: float | None = None
|
||||
result_notes: str = ""
|
||||
|
||||
class AgentLearnerPlanRequest(BaseModel):
|
||||
learner_id: str
|
||||
pack_id: str
|
||||
|
||||
class AgentLearnerPlanResponse(BaseModel):
|
||||
learner_id: str
|
||||
pack_id: str
|
||||
next_cards: list[dict] = Field(default_factory=list)
|
||||
suggested_actions: list[str] = Field(default_factory=list)
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ class PackORM(Base):
|
|||
__tablename__ = "packs"
|
||||
id: Mapped[str] = mapped_column(String(100), primary_key=True)
|
||||
owner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
policy_lane: Mapped[str] = mapped_column(String(50), default="personal")
|
||||
policy_lane: Mapped[str] = mapped_column(String(50), default="personal") # personal | community
|
||||
title: Mapped[str] = mapped_column(String(255))
|
||||
subtitle: Mapped[str] = mapped_column(Text, default="")
|
||||
level: Mapped[str] = mapped_column(String(100), default="novice-friendly")
|
||||
|
|
|
|||
|
|
@ -4,47 +4,12 @@ from datetime import datetime, timezone
|
|||
from sqlalchemy import select
|
||||
from .db import SessionLocal
|
||||
from .orm import UserORM, RefreshTokenORM, PackORM, PackVersionORM, ReviewCommentORM, ContributionSubmissionORM, ReviewTaskORM, LearnerORM, MasteryRecordORM, EvidenceEventORM, EvaluatorJobORM
|
||||
from .models import PackData, LearnerState, MasteryRecord, EvidenceEvent, DeploymentPolicyProfile
|
||||
from .models import PackData, LearnerState, MasteryRecord, EvidenceEvent
|
||||
from .auth import verify_password
|
||||
from .config import load_settings
|
||||
|
||||
settings = load_settings()
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
def deployment_policy_profile() -> DeploymentPolicyProfile:
|
||||
profile = settings.deployment_policy_profile
|
||||
if profile == "community_repo":
|
||||
return DeploymentPolicyProfile(
|
||||
profile_name="community_repo",
|
||||
default_personal_lane_enabled=True,
|
||||
default_community_lane_enabled=True,
|
||||
community_publish_requires_approval=True,
|
||||
personal_publish_direct=True,
|
||||
reviewer_assignment_required=True,
|
||||
description="Shared repository deployment with stronger community controls."
|
||||
)
|
||||
if profile == "team_lab":
|
||||
return DeploymentPolicyProfile(
|
||||
profile_name="team_lab",
|
||||
default_personal_lane_enabled=True,
|
||||
default_community_lane_enabled=True,
|
||||
community_publish_requires_approval=True,
|
||||
personal_publish_direct=True,
|
||||
reviewer_assignment_required=False,
|
||||
description="Team deployment with shared review but moderate overhead."
|
||||
)
|
||||
return DeploymentPolicyProfile(
|
||||
profile_name="single_user",
|
||||
default_personal_lane_enabled=True,
|
||||
default_community_lane_enabled=True,
|
||||
community_publish_requires_approval=True,
|
||||
personal_publish_direct=True,
|
||||
reviewer_assignment_required=False,
|
||||
description="Single-user/private-first deployment."
|
||||
)
|
||||
|
||||
def pack_diff(old_pack: dict | None, new_pack: dict) -> dict:
|
||||
old_pack = old_pack or {}
|
||||
old_concepts = {c.get("id"): c for c in old_pack.get("concepts", [])}
|
||||
|
|
@ -219,9 +184,6 @@ def create_submission(pack: PackData, contributor_user_id: int, submission_summa
|
|||
proposed_payload = pack.model_dump()
|
||||
diff = pack_diff(current_payload, proposed_payload)
|
||||
gates = gate_summary(validation, provenance)
|
||||
task_note = "Community submission awaiting reviewer attention"
|
||||
if deployment_policy_profile().reviewer_assignment_required:
|
||||
task_note += " (reviewer assignment required by deployment policy)"
|
||||
sub = ContributionSubmissionORM(
|
||||
pack_id=pack.id,
|
||||
policy_lane="community",
|
||||
|
|
@ -240,7 +202,7 @@ def create_submission(pack: PackData, contributor_user_id: int, submission_summa
|
|||
submission_id=sub.id,
|
||||
reviewer_user_id=None,
|
||||
task_status="open",
|
||||
task_note=task_note,
|
||||
task_note="Community submission awaiting reviewer attention",
|
||||
created_at=now_iso(),
|
||||
))
|
||||
db.commit()
|
||||
|
|
@ -289,7 +251,7 @@ def can_publish_pack(pack_id: str) -> tuple[bool, str]:
|
|||
return False, "Pack not found"
|
||||
if row.policy_lane == "personal":
|
||||
return True, "Personal lane pack may publish directly"
|
||||
if deployment_policy_profile().community_publish_requires_approval and row.governance_state != "approved":
|
||||
if row.governance_state != "approved":
|
||||
return False, "Community lane pack must be approved before publication"
|
||||
validation = json.loads(row.validation_json or "{}")
|
||||
provenance = json.loads(row.provenance_json or "{}")
|
||||
|
|
@ -304,9 +266,14 @@ def set_pack_publication(pack_id: str, is_published: bool):
|
|||
if row is None:
|
||||
return False, "Pack not found"
|
||||
if is_published:
|
||||
ok, reason = can_publish_pack(pack_id)
|
||||
if not ok:
|
||||
return False, reason
|
||||
validation = json.loads(row.validation_json or "{}")
|
||||
provenance = json.loads(row.provenance_json or "{}")
|
||||
gates = gate_summary(validation, provenance)
|
||||
if row.policy_lane == "community":
|
||||
if row.governance_state != "approved":
|
||||
return False, "Community lane pack must be approved before publication"
|
||||
if not gates.get("ready_for_review", False):
|
||||
return False, "Community lane gates not satisfied"
|
||||
row.is_published = is_published
|
||||
db.commit()
|
||||
return True, "Updated"
|
||||
|
|
|
|||
|
|
@ -38,7 +38,9 @@ def main():
|
|||
title="Wesley Private Pack",
|
||||
subtitle="Personal pack example without community friction.",
|
||||
level="novice-friendly",
|
||||
concepts=[PackConcept(id="intro", title="Intro", prerequisites=[], masteryDimension="mastery", exerciseReward="Intro marker")],
|
||||
concepts=[
|
||||
PackConcept(id="intro", title="Intro", prerequisites=[], masteryDimension="mastery", exerciseReward="Intro marker"),
|
||||
],
|
||||
onboarding={"headline":"Start privately","body":"Personal pack lane.","checklist":["Create pack","Use pack"]},
|
||||
compliance=PackCompliance(sources=0, attributionRequired=False, shareAlikeRequired=False, noncommercialOnly=False, flags=[])
|
||||
),
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Didactopus Deployment Policy + Agent Hooks</title>
|
||||
<title>Didactopus Dual-Lane Policy Layer</title>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</head>
|
||||
<body><div id="root"></div></body>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "didactopus-deployment-policy-ui",
|
||||
"name": "didactopus-dual-lane-ui",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { login, refresh, fetchDeploymentPolicy, fetchAgentCapabilities, fetchPacks, upsertPack, createContribution, fetchAdminPacks, fetchPackValidation, fetchPackProvenance, fetchPackVersions, fetchPackComments, fetchSubmissions, fetchSubmissionDiff, fetchSubmissionGates, fetchReviewTasks, publishPack, fetchPublishability } from "./api";
|
||||
import { login, refresh, fetchPacks, upsertPack, createContribution, fetchAdminPacks, fetchPackValidation, fetchPackProvenance, fetchPackVersions, fetchPackComments, fetchSubmissions, fetchSubmissionDiff, fetchSubmissionGates, fetchReviewTasks, publishPack, fetchPublishability, governanceAction, addReviewComment } from "./api";
|
||||
import { loadAuth, saveAuth, clearAuth } from "./authStore";
|
||||
|
||||
function LoginView({ onAuth }) {
|
||||
|
|
@ -29,7 +29,6 @@ function LoginView({ onAuth }) {
|
|||
function NavTabs({ tab, setTab, role }) {
|
||||
return (
|
||||
<div className="tab-row">
|
||||
<button className={tab==="policy" ? "active-tab" : ""} onClick={() => setTab("policy")}>Policy & agent hooks</button>
|
||||
<button className={tab==="personal" ? "active-tab" : ""} onClick={() => setTab("personal")}>Personal packs</button>
|
||||
<button className={tab==="contribute" ? "active-tab" : ""} onClick={() => setTab("contribute")}>Community contribution</button>
|
||||
{role === "admin" ? <>
|
||||
|
|
@ -42,9 +41,7 @@ function NavTabs({ tab, setTab, role }) {
|
|||
|
||||
export default function App() {
|
||||
const [auth, setAuth] = useState(loadAuth());
|
||||
const [tab, setTab] = useState("policy");
|
||||
const [deploymentPolicy, setDeploymentPolicy] = useState(null);
|
||||
const [agentCapabilities, setAgentCapabilities] = useState(null);
|
||||
const [tab, setTab] = useState("personal");
|
||||
const [packs, setPacks] = useState([]);
|
||||
const [adminPacks, setAdminPacks] = useState([]);
|
||||
const [selectedPackId, setSelectedPackId] = useState("");
|
||||
|
|
@ -58,6 +55,8 @@ export default function App() {
|
|||
const [submissionGates, setSubmissionGates] = useState(null);
|
||||
const [publishability, setPublishability] = useState(null);
|
||||
const [reviewTasks, setReviewTasks] = useState([]);
|
||||
const [commentText, setCommentText] = useState("Looks structurally plausible.");
|
||||
const [reviewSummary, setReviewSummary] = useState("Reviewed and ready for next stage.");
|
||||
const [message, setMessage] = useState("");
|
||||
const [personalPack, setPersonalPack] = useState({
|
||||
id: "my-private-pack",
|
||||
|
|
@ -104,14 +103,11 @@ export default function App() {
|
|||
useEffect(() => {
|
||||
if (!auth) return;
|
||||
async function load() {
|
||||
setDeploymentPolicy(await guarded((token) => fetchDeploymentPolicy(token)));
|
||||
setAgentCapabilities(await guarded((token) => fetchAgentCapabilities(token)));
|
||||
const p = await guarded((token) => fetchPacks(token));
|
||||
setPacks(p);
|
||||
setSelectedPackId((prev) => prev || p[0]?.id || "");
|
||||
if (auth.role === "admin") {
|
||||
const ap = await guarded((token) => fetchAdminPacks(token));
|
||||
setAdminPacks(ap);
|
||||
setSelectedPackId((prev) => prev || ap[0]?.id || "");
|
||||
setAdminPacks(await guarded((token) => fetchAdminPacks(token)));
|
||||
setSubmissions(await guarded((token) => fetchSubmissions(token)));
|
||||
setReviewTasks(await guarded((token) => fetchReviewTasks(token)));
|
||||
}
|
||||
|
|
@ -151,6 +147,21 @@ export default function App() {
|
|||
setMessage(`Community submission created: ${result.submission_id}`);
|
||||
}
|
||||
|
||||
async function doGovernance(status) {
|
||||
await guarded((token) => governanceAction(token, selectedPackId, { status, review_summary: reviewSummary }));
|
||||
setAdminPacks(await guarded((token) => fetchAdminPacks(token)));
|
||||
setVersions(await guarded((token) => fetchPackVersions(token, selectedPackId)));
|
||||
setPublishability(await guarded((token) => fetchPublishability(token, selectedPackId)));
|
||||
setMessage(`Pack moved to ${status}`);
|
||||
}
|
||||
|
||||
async function addCommentNow() {
|
||||
const versionNumber = versions[0]?.version_number || 1;
|
||||
await guarded((token) => addReviewComment(token, selectedPackId, versionNumber, { comment_text: commentText, disposition: "comment" }));
|
||||
setComments(await guarded((token) => fetchPackComments(token, selectedPackId)));
|
||||
setMessage("Review comment added");
|
||||
}
|
||||
|
||||
async function publishSelected() {
|
||||
const result = await guarded((token) => publishPack(token, selectedPackId, true));
|
||||
setMessage(result.reason || "Publish updated");
|
||||
|
|
@ -164,8 +175,8 @@ export default function App() {
|
|||
<div className="page">
|
||||
<header className="hero">
|
||||
<div>
|
||||
<h1>Didactopus deployment policy + agent hooks</h1>
|
||||
<p>Policy profiles plus explicit API parity for human UI use and AI learner use.</p>
|
||||
<h1>Didactopus dual-lane policy layer</h1>
|
||||
<p>Personal packs stay low-friction. Community packs keep gates, review, and approval workflows.</p>
|
||||
<div className="muted">Signed in as {auth.username} ({auth.role})</div>
|
||||
{message ? <div className="message">{message}</div> : null}
|
||||
</div>
|
||||
|
|
@ -179,20 +190,6 @@ export default function App() {
|
|||
|
||||
<NavTabs tab={tab} setTab={setTab} role={auth.role} />
|
||||
|
||||
{tab === "policy" && (
|
||||
<main className="layout twocol">
|
||||
<section className="card">
|
||||
<h2>Deployment policy profile</h2>
|
||||
<pre className="prebox">{JSON.stringify(deploymentPolicy, null, 2)}</pre>
|
||||
</section>
|
||||
<section className="card">
|
||||
<h2>AI learner capability hooks</h2>
|
||||
<p className="muted">This installation exposes direct API hooks for an AI learner instead of requiring UI mediation.</p>
|
||||
<pre className="prebox">{JSON.stringify(agentCapabilities, null, 2)}</pre>
|
||||
</section>
|
||||
</main>
|
||||
)}
|
||||
|
||||
{tab === "personal" && (
|
||||
<main className="layout onecol">
|
||||
<section className="card">
|
||||
|
|
@ -257,7 +254,13 @@ export default function App() {
|
|||
<main className="layout twocol">
|
||||
<section className="card">
|
||||
<h2>Governance and publishability</h2>
|
||||
<button onClick={publishSelected}>Publish</button>
|
||||
<div className="button-row">
|
||||
<button onClick={() => doGovernance("in_review")}>Move to in_review</button>
|
||||
<button onClick={() => doGovernance("approved")}>Approve</button>
|
||||
<button onClick={() => doGovernance("rejected")}>Reject</button>
|
||||
<button onClick={publishSelected}>Publish</button>
|
||||
</div>
|
||||
<label>Review summary<textarea value={reviewSummary} onChange={(e) => setReviewSummary(e.target.value)} /></label>
|
||||
<h3>Publishability</h3>
|
||||
<pre className="prebox">{JSON.stringify(publishability, null, 2)}</pre>
|
||||
<h3>Validation</h3>
|
||||
|
|
@ -269,6 +272,8 @@ export default function App() {
|
|||
<h2>Versions and comments</h2>
|
||||
<h3>Versions</h3>
|
||||
<pre className="prebox">{JSON.stringify(versions, null, 2)}</pre>
|
||||
<label>Reviewer comment<textarea value={commentText} onChange={(e) => setCommentText(e.target.value)} /></label>
|
||||
<button className="primary" onClick={addCommentNow}>Add comment</button>
|
||||
<h3>Comments</h3>
|
||||
<pre className="prebox">{JSON.stringify(comments, null, 2)}</pre>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -16,8 +16,6 @@ export async function refresh(refreshToken) {
|
|||
if (!res.ok) throw new Error("refresh failed");
|
||||
return await res.json();
|
||||
}
|
||||
export async function fetchDeploymentPolicy(token) { const res = await fetch(`${API}/deployment-policy`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchDeploymentPolicy failed"); return await res.json(); }
|
||||
export async function fetchAgentCapabilities(token) { const res = await fetch(`${API}/agent/capabilities`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchAgentCapabilities failed"); return await res.json(); }
|
||||
export async function fetchPacks(token) { const res = await fetch(`${API}/packs`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchPacks failed"); return await res.json(); }
|
||||
export async function upsertPack(token, payload) { const res = await fetch(`${API}/packs`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("upsertPack failed"); return await res.json(); }
|
||||
export async function createContribution(token, payload) { const res = await fetch(`${API}/contributions`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("createContribution failed"); return await res.json(); }
|
||||
|
|
@ -32,3 +30,5 @@ export async function fetchSubmissionGates(token, submissionId) { const res = aw
|
|||
export async function fetchReviewTasks(token) { const res = await fetch(`${API}/admin/review-tasks`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchReviewTasks failed"); return await res.json(); }
|
||||
export async function publishPack(token, packId, isPublished) { const res = await fetch(`${API}/admin/packs/${packId}/publish?is_published=${isPublished}`, { method: "POST", headers: authHeaders(token, false) }); if (!res.ok) throw new Error("publishPack failed"); return await res.json(); }
|
||||
export async function fetchPublishability(token, packId) { const res = await fetch(`${API}/admin/packs/${packId}/publishability`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchPublishability failed"); return await res.json(); }
|
||||
export async function governanceAction(token, packId, payload) { const res = await fetch(`${API}/admin/packs/${packId}/governance`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("governanceAction failed"); return await res.json(); }
|
||||
export async function addReviewComment(token, packId, versionNumber, payload) { const res = await fetch(`${API}/admin/packs/${packId}/comments?version_number=${versionNumber}`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("addReviewComment failed"); return await res.json(); }
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ input, select, textarea { width:100%; margin-top:6px; border:1px solid var(--bor
|
|||
.table { width:100%; border-collapse:collapse; }
|
||||
.table th, .table td { border-bottom:1px solid var(--border); text-align:left; padding:8px; vertical-align:top; }
|
||||
.prebox { background:#f7f8fa; border:1px solid var(--border); border-radius:12px; padding:12px; overflow:auto; max-height:320px; }
|
||||
.button-row { display:flex; gap:10px; flex-wrap:wrap; margin-bottom:12px; }
|
||||
@media (max-width:1100px) {
|
||||
.hero { flex-direction:column; }
|
||||
.twocol { grid-template-columns:1fr; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue