Compare commits
1 Commits
mimo/creat
...
mimo/code/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8057e7d00c |
43
app.js
43
app.js
@@ -2405,7 +2405,18 @@ function checkPortalProximity() {
|
||||
activePortal = closest;
|
||||
const hint = document.getElementById('portal-hint');
|
||||
if (activePortal) {
|
||||
document.getElementById('portal-hint-name').textContent = activePortal.config.name;
|
||||
const cfg = activePortal.config;
|
||||
document.getElementById('portal-hint-name').textContent = cfg.name;
|
||||
document.getElementById('portal-hint-desc').textContent = cfg.description || '';
|
||||
document.getElementById('portal-hint-purpose').textContent = cfg.purpose || cfg.description || '\u2014';
|
||||
document.getElementById('portal-hint-access').textContent = (cfg.access_mode || 'open').toUpperCase();
|
||||
document.getElementById('portal-hint-interaction').textContent = cfg.meaningful_interaction || '\u2014';
|
||||
|
||||
const readinessEl = document.getElementById('portal-hint-readiness');
|
||||
const readiness = cfg.readiness || cfg.status || 'online';
|
||||
readinessEl.textContent = readiness.toUpperCase();
|
||||
readinessEl.className = 'portal-preview-readiness readiness-' + readiness;
|
||||
|
||||
hint.style.display = 'flex';
|
||||
} else {
|
||||
hint.style.display = 'none';
|
||||
@@ -2422,10 +2433,20 @@ function activatePortal(portal) {
|
||||
const timerDisplay = document.getElementById('portal-timer');
|
||||
const statusDot = document.getElementById('portal-status-dot');
|
||||
|
||||
nameDisplay.textContent = portal.config.name.toUpperCase();
|
||||
descDisplay.textContent = portal.config.description;
|
||||
statusDot.style.background = portal.config.color;
|
||||
statusDot.style.boxShadow = `0 0 10px ${portal.config.color}`;
|
||||
const cfg = portal.config;
|
||||
nameDisplay.textContent = cfg.name.toUpperCase();
|
||||
descDisplay.textContent = cfg.description;
|
||||
statusDot.style.background = cfg.color;
|
||||
statusDot.style.boxShadow = `0 0 10px ${cfg.color}`;
|
||||
|
||||
// Populate destination preview details
|
||||
document.getElementById('portal-purpose-display').textContent = cfg.purpose || cfg.description || '\u2014';
|
||||
const readinessEl = document.getElementById('portal-readiness-display');
|
||||
const readiness = cfg.readiness || cfg.status || 'online';
|
||||
readinessEl.textContent = readiness.toUpperCase();
|
||||
readinessEl.className = 'portal-readiness readiness-' + readiness;
|
||||
document.getElementById('portal-access-display').textContent = (cfg.access_mode || 'open').toUpperCase();
|
||||
document.getElementById('portal-interaction-display').textContent = cfg.meaningful_interaction || '\u2014';
|
||||
|
||||
overlay.style.display = 'flex';
|
||||
|
||||
@@ -2536,6 +2557,18 @@ function populateAtlas() {
|
||||
<div class="atlas-card-status ${statusClass}">${config.status || 'ONLINE'}</div>
|
||||
</div>
|
||||
<div class="atlas-card-desc">${config.description}</div>
|
||||
${config.purpose ? `<div class="atlas-card-row"><span class="atlas-card-label">PURPOSE</span> ${config.purpose}</div>` : ''}
|
||||
<div class="atlas-card-meta">
|
||||
<div class="atlas-card-meta-item">
|
||||
<span class="atlas-card-label">READINESS</span>
|
||||
<span class="atlas-card-readiness readiness-${config.readiness || config.status || 'online'}">${(config.readiness || config.status || 'online').toUpperCase()}</span>
|
||||
</div>
|
||||
<div class="atlas-card-meta-item">
|
||||
<span class="atlas-card-label">ACCESS</span>
|
||||
<span>${(config.access_mode || 'open').toUpperCase()}</span>
|
||||
</div>
|
||||
</div>
|
||||
${config.meaningful_interaction ? `<div class="atlas-card-row"><span class="atlas-card-label">INTERACTION</span> ${config.meaningful_interaction}</div>` : ''}
|
||||
<div class="atlas-card-footer">
|
||||
<div class="atlas-card-coord">X:${config.position.x} Z:${config.position.z}</div>
|
||||
<div class="atlas-card-type">${config.destination?.type?.toUpperCase() || 'UNKNOWN'}</div>
|
||||
|
||||
Binary file not shown.
@@ -60,23 +60,6 @@ If the heartbeat is older than --stale-threshold seconds, the
|
||||
mind is considered dead even if the process is still running
|
||||
(e.g., hung on a blocking call).
|
||||
|
||||
KIMI HEARTBEAT
|
||||
==============
|
||||
The Kimi triage pipeline writes a cron heartbeat file after each run:
|
||||
|
||||
/var/run/bezalel/heartbeats/kimi-heartbeat.last
|
||||
(fallback: ~/.bezalel/heartbeats/kimi-heartbeat.last)
|
||||
{
|
||||
"job": "kimi-heartbeat",
|
||||
"timestamp": 1711843200.0,
|
||||
"interval_seconds": 900,
|
||||
"pid": 12345,
|
||||
"status": "ok"
|
||||
}
|
||||
|
||||
If the heartbeat is stale (>2x declared interval), the watchdog reports
|
||||
a Kimi Heartbeat failure alongside the other checks.
|
||||
|
||||
ZERO DEPENDENCIES
|
||||
=================
|
||||
Pure stdlib. No pip installs. Same machine as the nexus.
|
||||
@@ -121,10 +104,6 @@ DEFAULT_HEARTBEAT_PATH = Path.home() / ".nexus" / "heartbeat.json"
|
||||
DEFAULT_STALE_THRESHOLD = 300 # 5 minutes without a heartbeat = dead
|
||||
DEFAULT_INTERVAL = 60 # seconds between checks in watch mode
|
||||
|
||||
# Kimi Heartbeat — cron job heartbeat file written by the triage pipeline
|
||||
KIMI_HEARTBEAT_JOB = "kimi-heartbeat"
|
||||
KIMI_HEARTBEAT_STALE_MULTIPLIER = 2.0 # stale at 2x declared interval
|
||||
|
||||
GITEA_URL = os.environ.get("GITEA_URL", "https://forge.alexanderwhitestone.com")
|
||||
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "")
|
||||
GITEA_REPO = os.environ.get("NEXUS_REPO", "Timmy_Foundation/the-nexus")
|
||||
@@ -366,93 +345,6 @@ def check_syntax_health() -> CheckResult:
|
||||
)
|
||||
|
||||
|
||||
def check_kimi_heartbeat(
|
||||
job: str = KIMI_HEARTBEAT_JOB,
|
||||
stale_multiplier: float = KIMI_HEARTBEAT_STALE_MULTIPLIER,
|
||||
) -> CheckResult:
|
||||
"""Check if the Kimi Heartbeat cron job is alive.
|
||||
|
||||
Reads the ``<job>.last`` file from the standard Bezalel heartbeat
|
||||
directory (``/var/run/bezalel/heartbeats/`` or fallback
|
||||
``~/.bezalel/heartbeats/``). The file is written atomically by the
|
||||
cron_heartbeat module after each successful triage pipeline run.
|
||||
|
||||
A job is stale when:
|
||||
``time.time() - timestamp > stale_multiplier * interval_seconds``
|
||||
(same rule used by ``check_cron_heartbeats.py``).
|
||||
"""
|
||||
# Resolve heartbeat directory — same logic as cron_heartbeat._resolve
|
||||
primary = Path("/var/run/bezalel/heartbeats")
|
||||
fallback = Path.home() / ".bezalel" / "heartbeats"
|
||||
env_dir = os.environ.get("BEZALEL_HEARTBEAT_DIR")
|
||||
if env_dir:
|
||||
hb_dir = Path(env_dir)
|
||||
elif primary.exists():
|
||||
hb_dir = primary
|
||||
elif fallback.exists():
|
||||
hb_dir = fallback
|
||||
else:
|
||||
return CheckResult(
|
||||
name="Kimi Heartbeat",
|
||||
healthy=False,
|
||||
message="Heartbeat directory not found — no triage pipeline deployed yet",
|
||||
details={"searched": [str(primary), str(fallback)]},
|
||||
)
|
||||
|
||||
hb_file = hb_dir / f"{job}.last"
|
||||
if not hb_file.exists():
|
||||
return CheckResult(
|
||||
name="Kimi Heartbeat",
|
||||
healthy=False,
|
||||
message=f"No heartbeat file at {hb_file} — Kimi triage pipeline has never reported",
|
||||
details={"path": str(hb_file)},
|
||||
)
|
||||
|
||||
try:
|
||||
data = json.loads(hb_file.read_text())
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
return CheckResult(
|
||||
name="Kimi Heartbeat",
|
||||
healthy=False,
|
||||
message=f"Heartbeat file corrupt: {e}",
|
||||
details={"path": str(hb_file), "error": str(e)},
|
||||
)
|
||||
|
||||
timestamp = float(data.get("timestamp", 0))
|
||||
interval = int(data.get("interval_seconds", 0))
|
||||
raw_status = data.get("status", "unknown")
|
||||
age = time.time() - timestamp
|
||||
|
||||
if interval <= 0:
|
||||
# No declared interval — use raw timestamp age (30 min default)
|
||||
interval = 1800
|
||||
|
||||
threshold = stale_multiplier * interval
|
||||
is_stale = age > threshold
|
||||
|
||||
age_str = f"{int(age)}s" if age < 3600 else f"{int(age // 3600)}h {int((age % 3600) // 60)}m"
|
||||
interval_str = f"{int(interval)}s" if interval < 3600 else f"{int(interval // 3600)}h {int((interval % 3600) // 60)}m"
|
||||
|
||||
if is_stale:
|
||||
return CheckResult(
|
||||
name="Kimi Heartbeat",
|
||||
healthy=False,
|
||||
message=(
|
||||
f"Silent for {age_str} "
|
||||
f"(threshold: {stale_multiplier}x {interval_str} = {int(threshold)}s). "
|
||||
f"Status: {raw_status}"
|
||||
),
|
||||
details=data,
|
||||
)
|
||||
|
||||
return CheckResult(
|
||||
name="Kimi Heartbeat",
|
||||
healthy=True,
|
||||
message=f"Alive — last beat {age_str} ago (interval {interval_str}, status={raw_status})",
|
||||
details=data,
|
||||
)
|
||||
|
||||
|
||||
# ── Gitea alerting ───────────────────────────────────────────────────
|
||||
|
||||
def _gitea_request(method: str, path: str, data: Optional[dict] = None) -> Any:
|
||||
@@ -554,7 +446,6 @@ def run_health_checks(
|
||||
check_mind_process(),
|
||||
check_heartbeat(heartbeat_path, stale_threshold),
|
||||
check_syntax_health(),
|
||||
check_kimi_heartbeat(),
|
||||
]
|
||||
return HealthReport(timestamp=time.time(), checks=checks)
|
||||
|
||||
@@ -654,14 +545,6 @@ def main():
|
||||
"--json", action="store_true", dest="output_json",
|
||||
help="Output results as JSON (for integration with other tools)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--kimi-job", default=KIMI_HEARTBEAT_JOB,
|
||||
help=f"Kimi heartbeat job name (default: {KIMI_HEARTBEAT_JOB})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--kimi-stale-multiplier", type=float, default=KIMI_HEARTBEAT_STALE_MULTIPLIER,
|
||||
help=f"Kimi heartbeat staleness multiplier (default: {KIMI_HEARTBEAT_STALE_MULTIPLIER})",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
141
portals.json
141
portals.json
@@ -5,13 +5,25 @@
|
||||
"description": "The Vvardenfell harness. Ash storms and ancient mysteries.",
|
||||
"status": "online",
|
||||
"color": "#ff6600",
|
||||
"position": { "x": 15, "y": 0, "z": -10 },
|
||||
"rotation": { "y": -0.5 },
|
||||
"position": {
|
||||
"x": 15,
|
||||
"y": 0,
|
||||
"z": -10
|
||||
},
|
||||
"rotation": {
|
||||
"y": -0.5
|
||||
},
|
||||
"destination": {
|
||||
"url": "https://morrowind.timmy.foundation",
|
||||
"type": "harness",
|
||||
"params": { "world": "vvardenfell" }
|
||||
}
|
||||
"params": {
|
||||
"world": "vvardenfell"
|
||||
}
|
||||
},
|
||||
"purpose": "Game world \u2014 exploration, combat, and role-playing in Vvardenfell",
|
||||
"meaningful_interaction": "Autonomous questing, combat encounters, conversation with NPCs via agent harness",
|
||||
"access_mode": "open",
|
||||
"readiness": "online"
|
||||
},
|
||||
{
|
||||
"id": "bannerlord",
|
||||
@@ -19,8 +31,14 @@
|
||||
"description": "Calradia battle harness. Massive armies, tactical command.",
|
||||
"status": "active",
|
||||
"color": "#ffd700",
|
||||
"position": { "x": -15, "y": 0, "z": -10 },
|
||||
"rotation": { "y": 0.5 },
|
||||
"position": {
|
||||
"x": -15,
|
||||
"y": 0,
|
||||
"z": -10
|
||||
},
|
||||
"rotation": {
|
||||
"y": 0.5
|
||||
},
|
||||
"portal_type": "game-world",
|
||||
"world_category": "strategy-rpg",
|
||||
"environment": "production",
|
||||
@@ -34,8 +52,13 @@
|
||||
"url": "https://bannerlord.timmy.foundation",
|
||||
"type": "harness",
|
||||
"action_label": "Enter Calradia",
|
||||
"params": { "world": "calradia" }
|
||||
}
|
||||
"params": {
|
||||
"world": "calradia"
|
||||
}
|
||||
},
|
||||
"purpose": "Strategy RPG \u2014 tactical army command and battlefield control",
|
||||
"meaningful_interaction": "Agent-driven campaign, diplomacy, real-time battle command",
|
||||
"readiness": "active"
|
||||
},
|
||||
{
|
||||
"id": "workshop",
|
||||
@@ -43,13 +66,25 @@
|
||||
"description": "The creative harness. Build, script, and manifest.",
|
||||
"status": "online",
|
||||
"color": "#4af0c0",
|
||||
"position": { "x": 0, "y": 0, "z": -20 },
|
||||
"rotation": { "y": 0 },
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": -20
|
||||
},
|
||||
"rotation": {
|
||||
"y": 0
|
||||
},
|
||||
"destination": {
|
||||
"url": "https://workshop.timmy.foundation",
|
||||
"type": "harness",
|
||||
"params": { "mode": "creative" }
|
||||
}
|
||||
"params": {
|
||||
"mode": "creative"
|
||||
}
|
||||
},
|
||||
"purpose": "Creative sandbox \u2014 build tools, scripts, and artifacts",
|
||||
"meaningful_interaction": "Code execution, file creation, prototype building with agent assistance",
|
||||
"access_mode": "open",
|
||||
"readiness": "online"
|
||||
},
|
||||
{
|
||||
"id": "archive",
|
||||
@@ -57,13 +92,25 @@
|
||||
"description": "The repository of all knowledge. History, logs, and ancient data.",
|
||||
"status": "online",
|
||||
"color": "#0066ff",
|
||||
"position": { "x": 25, "y": 0, "z": 0 },
|
||||
"rotation": { "y": -1.57 },
|
||||
"position": {
|
||||
"x": 25,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"rotation": {
|
||||
"y": -1.57
|
||||
},
|
||||
"destination": {
|
||||
"url": "https://archive.timmy.foundation",
|
||||
"type": "harness",
|
||||
"params": { "mode": "read" }
|
||||
}
|
||||
"params": {
|
||||
"mode": "read"
|
||||
}
|
||||
},
|
||||
"purpose": "Knowledge repository \u2014 logs, history, and stored data",
|
||||
"meaningful_interaction": "Search, retrieve, analyze historical records and documents",
|
||||
"access_mode": "read-only",
|
||||
"readiness": "online"
|
||||
},
|
||||
{
|
||||
"id": "chapel",
|
||||
@@ -71,13 +118,25 @@
|
||||
"description": "A sanctuary for reflection and digital peace.",
|
||||
"status": "online",
|
||||
"color": "#ffd700",
|
||||
"position": { "x": -25, "y": 0, "z": 0 },
|
||||
"rotation": { "y": 1.57 },
|
||||
"position": {
|
||||
"x": -25,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"rotation": {
|
||||
"y": 1.57
|
||||
},
|
||||
"destination": {
|
||||
"url": "https://chapel.timmy.foundation",
|
||||
"type": "harness",
|
||||
"params": { "mode": "meditation" }
|
||||
}
|
||||
"params": {
|
||||
"mode": "meditation"
|
||||
}
|
||||
},
|
||||
"purpose": "Sanctuary \u2014 digital peace and reflection space",
|
||||
"meaningful_interaction": "Meditation interface, contemplative atmosphere, no active tasks",
|
||||
"access_mode": "open",
|
||||
"readiness": "online"
|
||||
},
|
||||
{
|
||||
"id": "courtyard",
|
||||
@@ -85,13 +144,25 @@
|
||||
"description": "The open nexus. A place for agents to gather and connect.",
|
||||
"status": "online",
|
||||
"color": "#4af0c0",
|
||||
"position": { "x": 15, "y": 0, "z": 10 },
|
||||
"rotation": { "y": -2.5 },
|
||||
"position": {
|
||||
"x": 15,
|
||||
"y": 0,
|
||||
"z": 10
|
||||
},
|
||||
"rotation": {
|
||||
"y": -2.5
|
||||
},
|
||||
"destination": {
|
||||
"url": "https://courtyard.timmy.foundation",
|
||||
"type": "harness",
|
||||
"params": { "mode": "social" }
|
||||
}
|
||||
"params": {
|
||||
"mode": "social"
|
||||
}
|
||||
},
|
||||
"purpose": "Social nexus \u2014 agent gathering and connection point",
|
||||
"meaningful_interaction": "Agent presence, inter-agent communication, shared context",
|
||||
"access_mode": "open",
|
||||
"readiness": "online"
|
||||
},
|
||||
{
|
||||
"id": "gate",
|
||||
@@ -99,12 +170,24 @@
|
||||
"description": "The transition point. Entry and exit from the Nexus core.",
|
||||
"status": "standby",
|
||||
"color": "#ff4466",
|
||||
"position": { "x": -15, "y": 0, "z": 10 },
|
||||
"rotation": { "y": 2.5 },
|
||||
"position": {
|
||||
"x": -15,
|
||||
"y": 0,
|
||||
"z": 10
|
||||
},
|
||||
"rotation": {
|
||||
"y": 2.5
|
||||
},
|
||||
"destination": {
|
||||
"url": "https://gate.timmy.foundation",
|
||||
"type": "harness",
|
||||
"params": { "mode": "transit" }
|
||||
}
|
||||
"params": {
|
||||
"mode": "transit"
|
||||
}
|
||||
},
|
||||
"purpose": "Transit point \u2014 entry and exit from Nexus core",
|
||||
"meaningful_interaction": "System transit, routing, session management",
|
||||
"access_mode": "open",
|
||||
"readiness": "standby"
|
||||
}
|
||||
]
|
||||
]
|
||||
187
style.css
187
style.css
@@ -383,6 +383,52 @@ canvas#nexus-canvas {
|
||||
font-size: 10px;
|
||||
color: rgba(160, 184, 208, 0.6);
|
||||
}
|
||||
.atlas-card-row {
|
||||
font-size: 12px;
|
||||
color: rgba(224, 240, 255, 0.65);
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.atlas-card-label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: var(--portal-color, var(--color-primary));
|
||||
letter-spacing: 0.1em;
|
||||
margin-right: 6px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.atlas-card-meta {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.atlas-card-meta-item {
|
||||
font-size: 11px;
|
||||
color: rgba(224, 240, 255, 0.6);
|
||||
}
|
||||
.atlas-card-readiness {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.atlas-card-readiness.readiness-online,
|
||||
.atlas-card-readiness.readiness-active {
|
||||
background: rgba(74, 240, 192, 0.12);
|
||||
color: #4af0c0;
|
||||
}
|
||||
.atlas-card-readiness.readiness-standby {
|
||||
background: rgba(255, 215, 0, 0.1);
|
||||
color: #ffd700;
|
||||
}
|
||||
.atlas-card-readiness.readiness-offline {
|
||||
background: rgba(255, 68, 102, 0.1);
|
||||
color: #ff4466;
|
||||
}
|
||||
|
||||
|
||||
.atlas-footer {
|
||||
padding: 15px 30px;
|
||||
@@ -653,6 +699,95 @@ canvas#nexus-canvas {
|
||||
border-radius: 4px;
|
||||
animation: hint-float 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Portal Preview Card */
|
||||
.portal-preview-card {
|
||||
background: rgba(10, 15, 30, 0.95);
|
||||
border: 1px solid var(--portal-color, var(--color-primary));
|
||||
border-radius: 6px;
|
||||
padding: 16px 20px;
|
||||
min-width: 300px;
|
||||
max-width: 400px;
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.portal-preview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.portal-preview-name {
|
||||
font-family: 'Orbitron', sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--portal-color, var(--color-primary));
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.portal-preview-readiness {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.portal-preview-readiness.readiness-online,
|
||||
.portal-preview-readiness.readiness-active {
|
||||
background: rgba(74, 240, 192, 0.15);
|
||||
color: #4af0c0;
|
||||
border: 1px solid rgba(74, 240, 192, 0.3);
|
||||
}
|
||||
.portal-preview-readiness.readiness-standby {
|
||||
background: rgba(255, 215, 0, 0.12);
|
||||
color: #ffd700;
|
||||
border: 1px solid rgba(255, 215, 0, 0.3);
|
||||
}
|
||||
.portal-preview-readiness.readiness-offline {
|
||||
background: rgba(255, 68, 102, 0.12);
|
||||
color: #ff4466;
|
||||
border: 1px solid rgba(255, 68, 102, 0.3);
|
||||
}
|
||||
.portal-preview-desc {
|
||||
font-size: 13px;
|
||||
color: rgba(224, 240, 255, 0.7);
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.portal-preview-meta {
|
||||
font-size: 12px;
|
||||
color: rgba(224, 240, 255, 0.6);
|
||||
margin-bottom: 6px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.portal-preview-label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: var(--portal-color, var(--color-primary));
|
||||
letter-spacing: 0.1em;
|
||||
margin-right: 6px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.portal-preview-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
font-size: 12px;
|
||||
color: rgba(224, 240, 255, 0.5);
|
||||
}
|
||||
.portal-hint-key {
|
||||
background: var(--portal-color, var(--color-primary));
|
||||
color: var(--color-bg);
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
@keyframes hint-float {
|
||||
0%, 100% { transform: translate(-50%, 100px); }
|
||||
50% { transform: translate(-50%, 90px); }
|
||||
@@ -842,6 +977,58 @@ canvas#nexus-canvas {
|
||||
text-align: center;
|
||||
padding: var(--space-8);
|
||||
}
|
||||
.portal-overlay-details {
|
||||
text-align: left;
|
||||
margin: 16px auto;
|
||||
max-width: 400px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(10, 15, 30, 0.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.portal-overlay-detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding: 6px 0;
|
||||
font-size: 13px;
|
||||
color: rgba(224, 240, 255, 0.7);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.portal-overlay-detail-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.portal-overlay-detail-label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
letter-spacing: 0.1em;
|
||||
opacity: 0.8;
|
||||
min-width: 90px;
|
||||
}
|
||||
.portal-readiness {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.portal-readiness.readiness-online,
|
||||
.portal-readiness.readiness-active {
|
||||
background: rgba(74, 240, 192, 0.12);
|
||||
color: #4af0c0;
|
||||
}
|
||||
.portal-readiness.readiness-standby {
|
||||
background: rgba(255, 215, 0, 0.1);
|
||||
color: #ffd700;
|
||||
}
|
||||
.portal-readiness.readiness-offline {
|
||||
background: rgba(255, 68, 102, 0.1);
|
||||
color: #ff4466;
|
||||
}
|
||||
|
||||
.portal-overlay-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user