Add launcher shell harness for membership and governance flows
This commit is contained in:
parent
b716b52f3f
commit
d0fcc2c119
19
README.md
19
README.md
@ -18,3 +18,22 @@ Free control-plane application for EDUT onboarding and entitlement-aware install
|
||||
## Boundary
|
||||
|
||||
Launcher never contains private kernel internals. It verifies and installs signed paid runtimes only after entitlement checks pass.
|
||||
|
||||
## Local Harness (Current)
|
||||
|
||||
`app/index.html` is a local launcher shell harness for end-to-end API validation:
|
||||
|
||||
1. Wallet intent + verify
|
||||
2. Membership quote + confirm
|
||||
3. Member channel register/poll/ack/support
|
||||
4. Governance install token/confirm/status
|
||||
5. Lease heartbeat + offline renew
|
||||
|
||||
Run locally:
|
||||
|
||||
```bash
|
||||
cd /Users/vsg/Documents/VSG\ Codex/launcher/app
|
||||
python3 -m http.server 4310
|
||||
```
|
||||
|
||||
Then open `http://127.0.0.1:4310` and point API base URL at running `secretapi`.
|
||||
|
||||
346
app/app.js
Normal file
346
app/app.js
Normal file
@ -0,0 +1,346 @@
|
||||
/* global fetch */
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
const state = {
|
||||
eventMap: new Map(),
|
||||
};
|
||||
|
||||
function nowISO() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function baseURL() {
|
||||
return $("apiBase").value.trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function wallet() {
|
||||
return $("walletAddress").value.trim();
|
||||
}
|
||||
|
||||
function chainID() {
|
||||
return Number($("chainId").value || 0);
|
||||
}
|
||||
|
||||
function logLine(label, payload) {
|
||||
const log = $("log");
|
||||
const line = `[${nowISO()}] ${label}\n${JSON.stringify(payload, null, 2)}\n\n`;
|
||||
log.textContent = line + log.textContent;
|
||||
}
|
||||
|
||||
async function request(method, path, body) {
|
||||
const opts = {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
};
|
||||
if (body !== undefined) {
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(`${baseURL()}${path}`, opts);
|
||||
const text = await res.text();
|
||||
let json = {};
|
||||
if (text.trim() !== "") {
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
json = { raw: text };
|
||||
}
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(`${res.status} ${res.statusText}: ${JSON.stringify(json)}`);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
function requireWallet() {
|
||||
const value = wallet();
|
||||
if (!value) {
|
||||
throw new Error("wallet is required");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function deviceID() {
|
||||
return $("deviceId").value.trim();
|
||||
}
|
||||
|
||||
function orgRootID() {
|
||||
return $("orgRootId").value.trim();
|
||||
}
|
||||
|
||||
function principalID() {
|
||||
return $("principalId").value.trim();
|
||||
}
|
||||
|
||||
function principalRole() {
|
||||
return $("principalRole").value.trim();
|
||||
}
|
||||
|
||||
function renderEvents(events) {
|
||||
const list = $("eventList");
|
||||
list.innerHTML = "";
|
||||
for (const evt of events) {
|
||||
state.eventMap.set(evt.event_id, evt);
|
||||
const card = document.createElement("article");
|
||||
card.className = "event";
|
||||
|
||||
const title = document.createElement("h3");
|
||||
title.textContent = `${evt.class} · ${evt.event_id}`;
|
||||
card.appendChild(title);
|
||||
|
||||
const body = document.createElement("p");
|
||||
body.textContent = evt.body || evt.title || "";
|
||||
card.appendChild(body);
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "meta";
|
||||
meta.textContent = `${evt.created_at} · scope=${evt.visibility_scope} · ack=${evt.requires_ack}`;
|
||||
card.appendChild(meta);
|
||||
|
||||
if (evt.requires_ack) {
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "actions";
|
||||
const button = document.createElement("button");
|
||||
button.textContent = "Ack";
|
||||
button.addEventListener("click", async () => {
|
||||
try {
|
||||
const out = await request("POST", `/member/channel/events/${evt.event_id}/ack`, {
|
||||
wallet: requireWallet(),
|
||||
device_id: deviceID(),
|
||||
acknowledged_at: nowISO(),
|
||||
});
|
||||
logLine(`event ack ${evt.event_id}`, out);
|
||||
} catch (err) {
|
||||
logLine(`event ack ${evt.event_id} error`, { error: String(err) });
|
||||
}
|
||||
});
|
||||
actions.appendChild(button);
|
||||
card.appendChild(actions);
|
||||
}
|
||||
list.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
async function onIntent() {
|
||||
const payload = {
|
||||
address: requireWallet(),
|
||||
origin: $("walletOrigin").value.trim() || "https://edut.ai",
|
||||
locale: $("walletLocale").value.trim() || "en",
|
||||
chain_id: chainID(),
|
||||
};
|
||||
const out = await request("POST", "/secret/wallet/intent", payload);
|
||||
$("intentId").value = out.intent_id || "";
|
||||
$("designationCode").value = out.designation_code || "";
|
||||
$("displayToken").value = out.display_token || "";
|
||||
logLine("wallet intent", out);
|
||||
}
|
||||
|
||||
async function onVerify() {
|
||||
const out = await request("POST", "/secret/wallet/verify", {
|
||||
intent_id: $("intentId").value.trim(),
|
||||
address: requireWallet(),
|
||||
chain_id: chainID(),
|
||||
signature: $("walletSignature").value.trim(),
|
||||
});
|
||||
if (out.designation_code) {
|
||||
$("designationCode").value = out.designation_code;
|
||||
}
|
||||
logLine("wallet verify", out);
|
||||
}
|
||||
|
||||
async function onStatus() {
|
||||
const out = await request(
|
||||
"GET",
|
||||
`/secret/membership/status?wallet=${encodeURIComponent(requireWallet())}`,
|
||||
);
|
||||
if (out.designation_code) {
|
||||
$("designationCode").value = out.designation_code;
|
||||
}
|
||||
logLine("membership status", out);
|
||||
}
|
||||
|
||||
async function onQuote() {
|
||||
const payload = {
|
||||
designation_code: $("designationCode").value.trim(),
|
||||
address: requireWallet(),
|
||||
chain_id: chainID(),
|
||||
};
|
||||
const payerWallet = $("payerWallet").value.trim();
|
||||
const payerProof = $("payerProof").value.trim();
|
||||
const sponsorOrgRoot = $("sponsorOrgRoot").value.trim();
|
||||
if (payerWallet) {
|
||||
payload.payer_wallet = payerWallet;
|
||||
}
|
||||
if (payerProof) {
|
||||
payload.payer_proof = payerProof;
|
||||
}
|
||||
if (sponsorOrgRoot) {
|
||||
payload.sponsor_org_root_id = sponsorOrgRoot;
|
||||
}
|
||||
const out = await request("POST", "/secret/membership/quote", payload);
|
||||
$("quoteId").value = out.quote_id || "";
|
||||
$("quoteValue").value = out.value || "";
|
||||
$("quotePayer").value = out.payer_wallet || "";
|
||||
logLine("membership quote", out);
|
||||
}
|
||||
|
||||
async function onConfirmMembership() {
|
||||
const out = await request("POST", "/secret/membership/confirm", {
|
||||
designation_code: $("designationCode").value.trim(),
|
||||
quote_id: $("quoteId").value.trim(),
|
||||
tx_hash: $("confirmTxHash").value.trim(),
|
||||
address: requireWallet(),
|
||||
chain_id: chainID(),
|
||||
});
|
||||
logLine("membership confirm", out);
|
||||
}
|
||||
|
||||
async function onRegisterChannel() {
|
||||
const out = await request("POST", "/member/channel/device/register", {
|
||||
wallet: requireWallet(),
|
||||
chain_id: chainID(),
|
||||
device_id: deviceID(),
|
||||
platform: $("platform").value.trim(),
|
||||
org_root_id: orgRootID(),
|
||||
principal_id: principalID(),
|
||||
principal_role: principalRole(),
|
||||
app_version: $("appVersion").value.trim(),
|
||||
push_provider: "none",
|
||||
});
|
||||
logLine("channel register", out);
|
||||
}
|
||||
|
||||
async function onUnregisterChannel() {
|
||||
const out = await request("POST", "/member/channel/device/unregister", {
|
||||
wallet: requireWallet(),
|
||||
device_id: deviceID(),
|
||||
});
|
||||
logLine("channel unregister", out);
|
||||
}
|
||||
|
||||
async function onPollEvents() {
|
||||
const cursor = $("eventCursor").value.trim();
|
||||
const limit = $("eventLimit").value.trim() || "25";
|
||||
const query = new URLSearchParams({
|
||||
wallet: requireWallet(),
|
||||
device_id: deviceID(),
|
||||
limit,
|
||||
});
|
||||
if (cursor) {
|
||||
query.set("cursor", cursor);
|
||||
}
|
||||
const out = await request("GET", `/member/channel/events?${query.toString()}`);
|
||||
if (out.next_cursor) {
|
||||
$("eventCursor").value = out.next_cursor;
|
||||
}
|
||||
renderEvents(out.events || []);
|
||||
logLine("channel poll", out);
|
||||
}
|
||||
|
||||
async function onSupportTicket() {
|
||||
const out = await request("POST", "/member/channel/support/ticket", {
|
||||
wallet: requireWallet(),
|
||||
org_root_id: orgRootID(),
|
||||
principal_id: principalID(),
|
||||
category: "admin_support",
|
||||
summary: $("supportSummary").value.trim(),
|
||||
context: {
|
||||
source: "launcher-shell",
|
||||
requested_at: nowISO(),
|
||||
},
|
||||
});
|
||||
logLine("support ticket", out);
|
||||
}
|
||||
|
||||
async function onInstallToken() {
|
||||
const out = await request("POST", "/governance/install/token", {
|
||||
wallet: requireWallet(),
|
||||
org_root_id: orgRootID(),
|
||||
principal_id: principalID(),
|
||||
principal_role: principalRole(),
|
||||
device_id: deviceID(),
|
||||
launcher_version: $("appVersion").value.trim(),
|
||||
platform: $("platform").value.trim(),
|
||||
});
|
||||
$("installToken").value = out.install_token || "";
|
||||
$("entitlementId").value = out.entitlement_id || "";
|
||||
$("runtimeVersion").value = out.package?.runtime_version || "";
|
||||
$("packageHash").value = out.package?.package_hash || "";
|
||||
logLine("install token", out);
|
||||
}
|
||||
|
||||
async function onInstallConfirm() {
|
||||
const out = await request("POST", "/governance/install/confirm", {
|
||||
install_token: $("installToken").value.trim(),
|
||||
wallet: requireWallet(),
|
||||
device_id: deviceID(),
|
||||
entitlement_id: $("entitlementId").value.trim(),
|
||||
package_hash: $("packageHash").value.trim(),
|
||||
runtime_version: $("runtimeVersion").value.trim(),
|
||||
installed_at: nowISO(),
|
||||
launcher_receipt_hash: `receipt-${Date.now()}`,
|
||||
});
|
||||
logLine("install confirm", out);
|
||||
}
|
||||
|
||||
async function onInstallStatus() {
|
||||
const query = new URLSearchParams({
|
||||
wallet: requireWallet(),
|
||||
device_id: deviceID(),
|
||||
});
|
||||
const out = await request("GET", `/governance/install/status?${query.toString()}`);
|
||||
logLine("install status", out);
|
||||
}
|
||||
|
||||
async function onLeaseHeartbeat() {
|
||||
const out = await request("POST", "/governance/lease/heartbeat", {
|
||||
wallet: requireWallet(),
|
||||
org_root_id: orgRootID(),
|
||||
principal_id: principalID(),
|
||||
device_id: deviceID(),
|
||||
});
|
||||
logLine("lease heartbeat", out);
|
||||
}
|
||||
|
||||
async function onOfflineRenew() {
|
||||
const out = await request("POST", "/governance/lease/offline-renew", {
|
||||
wallet: requireWallet(),
|
||||
org_root_id: orgRootID(),
|
||||
principal_id: principalID(),
|
||||
renewal_bundle: {
|
||||
bundle_id: `renew-${Date.now()}`,
|
||||
source: "launcher-shell",
|
||||
},
|
||||
});
|
||||
logLine("offline renew", out);
|
||||
}
|
||||
|
||||
function bind(id, handler) {
|
||||
$(id).addEventListener("click", async () => {
|
||||
try {
|
||||
await handler();
|
||||
} catch (err) {
|
||||
logLine(`${id} error`, { error: String(err) });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bind("btnIntent", onIntent);
|
||||
bind("btnVerify", onVerify);
|
||||
bind("btnStatus", onStatus);
|
||||
bind("btnQuote", onQuote);
|
||||
bind("btnConfirmMembership", onConfirmMembership);
|
||||
bind("btnRegisterChannel", onRegisterChannel);
|
||||
bind("btnUnregisterChannel", onUnregisterChannel);
|
||||
bind("btnPollEvents", onPollEvents);
|
||||
bind("btnSupportTicket", onSupportTicket);
|
||||
bind("btnInstallToken", onInstallToken);
|
||||
bind("btnInstallConfirm", onInstallConfirm);
|
||||
bind("btnInstallStatus", onInstallStatus);
|
||||
bind("btnLeaseHeartbeat", onLeaseHeartbeat);
|
||||
bind("btnOfflineRenew", onOfflineRenew);
|
||||
|
||||
logLine("launcher shell ready", {
|
||||
api_base: baseURL(),
|
||||
chain_id: chainID(),
|
||||
});
|
||||
217
app/index.html
Normal file
217
app/index.html
Normal file
@ -0,0 +1,217 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>EDUT Launcher Shell</title>
|
||||
<link rel="stylesheet" href="./style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<header class="hero">
|
||||
<h1>EDUT Launcher</h1>
|
||||
<p>Wallet-first onboarding shell (local integration harness)</p>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Connection</h2>
|
||||
<div class="grid two">
|
||||
<label>
|
||||
API base URL
|
||||
<input id="apiBase" value="http://127.0.0.1:8080" />
|
||||
</label>
|
||||
<label>
|
||||
Chain ID
|
||||
<input id="chainId" type="number" value="84532" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Wallet Intent</h2>
|
||||
<div class="grid three">
|
||||
<label>
|
||||
Wallet
|
||||
<input id="walletAddress" placeholder="0x..." />
|
||||
</label>
|
||||
<label>
|
||||
Origin
|
||||
<input id="walletOrigin" value="https://edut.ai" />
|
||||
</label>
|
||||
<label>
|
||||
Locale
|
||||
<input id="walletLocale" value="en" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button id="btnIntent">Create intent</button>
|
||||
<button id="btnStatus">Membership status</button>
|
||||
</div>
|
||||
<div class="grid three">
|
||||
<label>
|
||||
Intent ID
|
||||
<input id="intentId" />
|
||||
</label>
|
||||
<label>
|
||||
Designation code
|
||||
<input id="designationCode" />
|
||||
</label>
|
||||
<label>
|
||||
Display token
|
||||
<input id="displayToken" />
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
Signature (EIP-712)
|
||||
<textarea id="walletSignature" rows="2" placeholder="0x..."></textarea>
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button id="btnVerify">Verify signature</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Membership Quote + Confirm</h2>
|
||||
<div class="grid three">
|
||||
<label>
|
||||
Payer wallet (optional)
|
||||
<input id="payerWallet" placeholder="0x..." />
|
||||
</label>
|
||||
<label>
|
||||
Payer proof (optional)
|
||||
<input id="payerProof" placeholder="0x..." />
|
||||
</label>
|
||||
<label>
|
||||
Sponsor org root (optional)
|
||||
<input id="sponsorOrgRoot" placeholder="org_root_id" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button id="btnQuote">Get quote</button>
|
||||
</div>
|
||||
<div class="grid three">
|
||||
<label>
|
||||
Quote ID
|
||||
<input id="quoteId" />
|
||||
</label>
|
||||
<label>
|
||||
Tx value
|
||||
<input id="quoteValue" />
|
||||
</label>
|
||||
<label>
|
||||
Payer used
|
||||
<input id="quotePayer" />
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
Confirm tx hash
|
||||
<input id="confirmTxHash" placeholder="0x..." />
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button id="btnConfirmMembership">Confirm membership tx</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Member Channel</h2>
|
||||
<div class="grid three">
|
||||
<label>
|
||||
Device ID
|
||||
<input id="deviceId" value="desktop-local-01" />
|
||||
</label>
|
||||
<label>
|
||||
Platform
|
||||
<select id="platform">
|
||||
<option>desktop</option>
|
||||
<option>ios</option>
|
||||
<option>android</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
App version
|
||||
<input id="appVersion" value="0.1.0" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid three">
|
||||
<label>
|
||||
Org root ID
|
||||
<input id="orgRootId" value="org.local.root" />
|
||||
</label>
|
||||
<label>
|
||||
Principal ID
|
||||
<input id="principalId" value="human.local" />
|
||||
</label>
|
||||
<label>
|
||||
Principal role
|
||||
<select id="principalRole">
|
||||
<option>org_root_owner</option>
|
||||
<option>workspace_member</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button id="btnRegisterChannel">Register channel</button>
|
||||
<button id="btnUnregisterChannel">Unregister channel</button>
|
||||
</div>
|
||||
<div class="grid two">
|
||||
<label>
|
||||
Cursor
|
||||
<input id="eventCursor" />
|
||||
</label>
|
||||
<label>
|
||||
Limit
|
||||
<input id="eventLimit" type="number" value="25" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button id="btnPollEvents">Poll events</button>
|
||||
</div>
|
||||
<div id="eventList" class="event-list"></div>
|
||||
<label>
|
||||
Support summary (owner only)
|
||||
<input id="supportSummary" value="Need diagnostics snapshot." />
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button id="btnSupportTicket">Open support ticket</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Governance Install + Lease</h2>
|
||||
<div class="actions">
|
||||
<button id="btnInstallToken">Issue install token</button>
|
||||
<button id="btnInstallStatus">Install status</button>
|
||||
</div>
|
||||
<div class="grid three">
|
||||
<label>
|
||||
Install token
|
||||
<input id="installToken" />
|
||||
</label>
|
||||
<label>
|
||||
Entitlement ID
|
||||
<input id="entitlementId" />
|
||||
</label>
|
||||
<label>
|
||||
Runtime version
|
||||
<input id="runtimeVersion" />
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
Package hash
|
||||
<input id="packageHash" />
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button id="btnInstallConfirm">Confirm install</button>
|
||||
<button id="btnLeaseHeartbeat">Lease heartbeat</button>
|
||||
<button id="btnOfflineRenew">Offline renew</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Response Log</h2>
|
||||
<pre id="log"></pre>
|
||||
</section>
|
||||
</main>
|
||||
<script src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
167
app/style.css
Normal file
167
app/style.css
Normal file
@ -0,0 +1,167 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0b1220;
|
||||
--bg-soft: #121b2e;
|
||||
--line: #24344f;
|
||||
--text: #d8e2f2;
|
||||
--muted: #8fa2c2;
|
||||
--accent: #39c36b;
|
||||
--warn: #ffcd57;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Mono", "SF Mono", "Menlo", monospace;
|
||||
background: radial-gradient(circle at top, #182642 0%, var(--bg) 55%);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.shell {
|
||||
width: min(1180px, 94vw);
|
||||
margin: 20px auto 42px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.hero,
|
||||
.panel {
|
||||
background: color-mix(in oklab, var(--bg-soft) 88%, black);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0 0 6px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.grid.two {
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
}
|
||||
|
||||
.grid.three {
|
||||
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #0f1828;
|
||||
color: var(--text);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid #2d7f4a;
|
||||
background: #173325;
|
||||
color: #b8efcf;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.event-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 10px 0 6px;
|
||||
}
|
||||
|
||||
.event {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
background: #0d1625;
|
||||
}
|
||||
|
||||
.event h3 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 12px;
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.event p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.event .meta {
|
||||
margin-top: 7px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
#log {
|
||||
margin: 0;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: #08111d;
|
||||
border: 1px solid var(--line);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.shell {
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user