Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files
openenv/berth_openenv/environment.py
CHANGED
|
@@ -24,7 +24,7 @@ from typing import Annotated, Any
|
|
| 24 |
from fastmcp import FastMCP
|
| 25 |
from openenv.core.env_server.mcp_environment import MCPEnvironment
|
| 26 |
from openenv.core.env_server.mcp_types import CallToolAction
|
| 27 |
-
from openenv.core.env_server.types import Action, Observation, State
|
| 28 |
from pydantic import BaseModel, Field
|
| 29 |
|
| 30 |
from berth_core import PROMPT_VERSION, Task, TaskPack, evaluate, load_pack, parse_plan, plan_to_list, rules, situation
|
|
@@ -121,6 +121,18 @@ class BerthPlanningEnvironment(MCPEnvironment):
|
|
| 121 |
|
| 122 |
SUPPORTS_CONCURRENT_SESSIONS = True
|
| 123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
def __init__(self, pack: TaskPack | None = None):
|
| 125 |
self.pack = pack or load_pack()
|
| 126 |
self._episode: Episode | None = None
|
|
|
|
| 24 |
from fastmcp import FastMCP
|
| 25 |
from openenv.core.env_server.mcp_environment import MCPEnvironment
|
| 26 |
from openenv.core.env_server.mcp_types import CallToolAction
|
| 27 |
+
from openenv.core.env_server.types import Action, EnvironmentMetadata, Observation, State
|
| 28 |
from pydantic import BaseModel, Field
|
| 29 |
|
| 30 |
from berth_core import PROMPT_VERSION, Task, TaskPack, evaluate, load_pack, parse_plan, plan_to_list, rules, situation
|
|
|
|
| 121 |
|
| 122 |
SUPPORTS_CONCURRENT_SESSIONS = True
|
| 123 |
|
| 124 |
+
def get_metadata(self) -> EnvironmentMetadata:
|
| 125 |
+
"""What OpenEnv's /metadata reports (the web UI shows it too)."""
|
| 126 |
+
return EnvironmentMetadata(
|
| 127 |
+
name="PortSimEnv v1",
|
| 128 |
+
description="Re-plan a broken week of container-ship dockings at a Port of Barcelona quay, built from the "
|
| 129 |
+
"port's real 2024 calls. Tools: get_situation, check_plan (10 per episode), submit_plan "
|
| 130 |
+
"(graded once, deterministically, against the CP-SAT optimum).",
|
| 131 |
+
version="1.0.0",
|
| 132 |
+
author="FineEnvs",
|
| 133 |
+
documentation_url="https://github.com/adithya-s-k/FineEnvs/tree/main/07-simulation-environments/portsim-v1",
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
def __init__(self, pack: TaskPack | None = None):
|
| 137 |
self.pack = pack or load_pack()
|
| 138 |
self._episode: Episode | None = None
|
openenv/berth_openenv/gradio_ui.py
CHANGED
|
@@ -21,8 +21,8 @@ DEFAULT_SPLIT = "eval"
|
|
| 21 |
DEFAULT_TASK = "dock-24B-w07x1-busy-0"
|
| 22 |
|
| 23 |
|
| 24 |
-
def _iframe(task_id: str, split: str) -> str:
|
| 25 |
-
src = f"/viewer/?embed=1#/play?split={quote(split)}&task={quote(task_id)}&start=1"
|
| 26 |
return (
|
| 27 |
# OpenEnv's page caps the container width; the editor wants the full window.
|
| 28 |
"<style>.gradio-container .main.fillable{max-width:none!important;padding-left:12px!important;"
|
|
@@ -44,7 +44,7 @@ def build_ui(pack: TaskPack) -> gr.Blocks:
|
|
| 44 |
def _frame(index, split):
|
| 45 |
split = split if split in ids else split0
|
| 46 |
i = min(max(int(index or 0), 0), len(ids[split]) - 1)
|
| 47 |
-
return _iframe(ids[split][i], split)
|
| 48 |
|
| 49 |
with gr.Blocks(title="PortSimEnv v1") as ui:
|
| 50 |
with gr.Row():
|
|
|
|
| 21 |
DEFAULT_TASK = "dock-24B-w07x1-busy-0"
|
| 22 |
|
| 23 |
|
| 24 |
+
def _iframe(task_id: str, split: str, index: int) -> str:
|
| 25 |
+
src = f"/viewer/?embed=1#/play?split={quote(split)}&index={index}&task={quote(task_id)}&start=1"
|
| 26 |
return (
|
| 27 |
# OpenEnv's page caps the container width; the editor wants the full window.
|
| 28 |
"<style>.gradio-container .main.fillable{max-width:none!important;padding-left:12px!important;"
|
|
|
|
| 44 |
def _frame(index, split):
|
| 45 |
split = split if split in ids else split0
|
| 46 |
i = min(max(int(index or 0), 0), len(ids[split]) - 1)
|
| 47 |
+
return _iframe(ids[split][i], split, i)
|
| 48 |
|
| 49 |
with gr.Blocks(title="PortSimEnv v1") as ui:
|
| 50 |
with gr.Row():
|
openenv/berth_openenv/web/index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
<title>PortSimEnv v1 · Port of Barcelona</title>
|
| 7 |
<meta name="description" content="Re-planning a week of container-ship dockings at one quay of the Port of Barcelona after a disruption.">
|
| 8 |
-
<link rel="stylesheet" href="./style.css?v=eval-
|
| 9 |
<script type="importmap">
|
| 10 |
{
|
| 11 |
"imports": {
|
|
|
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
<title>PortSimEnv v1 · Port of Barcelona</title>
|
| 7 |
<meta name="description" content="Re-planning a week of container-ship dockings at one quay of the Port of Barcelona after a disruption.">
|
| 8 |
+
<link rel="stylesheet" href="./style.css?v=eval-2">
|
| 9 |
<script type="importmap">
|
| 10 |
{
|
| 11 |
"imports": {
|
openenv/berth_openenv/web/overview.js
CHANGED
|
@@ -55,6 +55,22 @@ function board(episodes) {
|
|
| 55 |
})).sort((a, b) => b.mean - a.mean);
|
| 56 |
}
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
function resultHtml(e) {
|
| 59 |
if (!e.submitted) return `<span class="muted" title="${escapeHtml(e.end_reason || "")}">not submitted</span>`;
|
| 60 |
if (!e.feasible) return '<i class="dot bad"></i>rule broken';
|
|
@@ -113,11 +129,16 @@ export async function overviewPage({ app, setCrumbs, isCurrent, params, config =
|
|
| 113 |
const evalSec = `<section>
|
| 114 |
<div class="sec-head"><h2>Eval</h2><span class="muted small">${RUN} · 50 held-out tasks · one rollout per model and task · 12 turns, 32k output tokens per turn</span></div>
|
| 115 |
<table class="tbl click" id="ov-board">
|
| 116 |
-
<thead><tr><th>Model</th><th class="num">Mean</th>${TIERS.map((t) => `<th class="num opt" title="${escapeHtml(TIER_TIPS[t])}">${cap(t)}</th>`).join("")}<th class="num" title="Episodes that ended with submit_plan">Submitted</th><th class="num" title="Plans that break no rule">Valid</th><th class="num" title="Plans that match the proven optimum (reward 1.0)">Optimal</th></tr></thead>
|
| 117 |
-
<tbody>${Array.from({ length: 6 }, () => `<tr class="skel">${"<td><i></i></td>".repeat(
|
| 118 |
</table>
|
|
|
|
| 119 |
<p class="muted small ov-note" id="ov-board-note">Mean reward per model and tier. Click a model to list its rollouts; each one replays in 3D.</p>
|
| 120 |
</section>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
<section class="ov-eps" hidden>
|
| 122 |
<div class="sec-head"><h2 id="ov-eps-h">Rollouts</h2><span class="muted small" id="ov-eps-note"></span></div>
|
| 123 |
<table class="tbl click" id="ov-eps"><thead></thead><tbody></tbody></table>
|
|
@@ -198,7 +219,7 @@ print(step.reward) # 0..1, graded once</code></pre>
|
|
| 198 |
|
| 199 |
const tbody = app.querySelector("#ov-board tbody");
|
| 200 |
const noRollouts = () => {
|
| 201 |
-
tbody.innerHTML = `<tr><td colspan="
|
| 202 |
app.querySelector("#ov-board-note").hidden = true;
|
| 203 |
if (explorer) return;
|
| 204 |
for (const a of app.querySelectorAll('a[href^="#/run/"]')) { // the showcase rollout: watch it in the eval Space
|
|
@@ -218,7 +239,7 @@ print(step.reward) # 0..1, graded once</code></pre>
|
|
| 218 |
if (!isCurrent()) return;
|
| 219 |
if (!rows.length) return noRollouts();
|
| 220 |
tbody.innerHTML = rows.map((r) => `<tr data-row="${escapeHtml(r.model)}" title="List ${escapeHtml(r.name)}'s rollouts">
|
| 221 |
-
<td>${escapeHtml(r.name)}</td><td class="num"><b>${fmtNum(r.mean, 3)}</b></td>
|
| 222 |
${TIERS.map((t) => `<td class="num opt">${r.tiers[t] == null ? "–" : fmtNum(r.tiers[t], 2)}</td>`).join("")}
|
| 223 |
<td class="num">${r.submitted}/${r.n}</td><td class="num">${r.valid}/${r.n}</td><td class="num">${r.optimal}/${r.n}</td></tr>`).join("");
|
| 224 |
|
|
@@ -226,6 +247,24 @@ print(step.reward) # 0..1, graded once</code></pre>
|
|
| 226 |
for (const e of rows[0].eps) perTier.set(e.difficulty, (perTier.get(e.difficulty) || 0) + 1);
|
| 227 |
app.querySelector("#ov-board-note").textContent = `Mean reward per model and tier (${TIERS.filter((t) => perTier.has(t)).map((t) => `${t} ${perTier.get(t)}`).join(", ")} tasks). Click a model to list its rollouts; each one replays in 3D.`;
|
| 228 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
// The selected model's rollouts, under the board (#/?model=<id> keeps the choice in shared links).
|
| 230 |
const epsSec = app.querySelector(".ov-eps");
|
| 231 |
let epsTable = null;
|
|
|
|
| 55 |
})).sort((a, b) => b.mean - a.mean);
|
| 56 |
}
|
| 57 |
|
| 58 |
+
// Outcome of one rollout, for the bars and the grid.
|
| 59 |
+
const OUTCOMES = [
|
| 60 |
+
{ k: "optimal", label: "optimal plan (1.0)" },
|
| 61 |
+
{ k: "valid", label: "valid, not optimal" },
|
| 62 |
+
{ k: "broken", label: "broke a rule (≤ 0.2)" },
|
| 63 |
+
{ k: "none", label: "no plan submitted (0)" },
|
| 64 |
+
];
|
| 65 |
+
const outcomeOf = (e) => (!e.submitted ? "none" : !e.feasible ? "broken" : (e.reward || 0) >= 0.999 ? "optimal" : "valid");
|
| 66 |
+
|
| 67 |
+
function outcomeBar(r) {
|
| 68 |
+
const n = r.n || 1;
|
| 69 |
+
const c = { optimal: r.optimal, valid: r.valid - r.optimal, broken: r.submitted - r.valid, none: r.n - r.submitted };
|
| 70 |
+
return `<span class="ov-obar" title="${OUTCOMES.map((o) => `${c[o.k]} ${o.label}`).join(" · ")}">${OUTCOMES.filter((o) => c[o.k] > 0)
|
| 71 |
+
.map((o) => `<i class="oc-${o.k}" style="width:${((100 * c[o.k]) / n).toFixed(2)}%"></i>`).join("")}</span>`;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
function resultHtml(e) {
|
| 75 |
if (!e.submitted) return `<span class="muted" title="${escapeHtml(e.end_reason || "")}">not submitted</span>`;
|
| 76 |
if (!e.feasible) return '<i class="dot bad"></i>rule broken';
|
|
|
|
| 129 |
const evalSec = `<section>
|
| 130 |
<div class="sec-head"><h2>Eval</h2><span class="muted small">${RUN} · 50 held-out tasks · one rollout per model and task · 12 turns, 32k output tokens per turn</span></div>
|
| 131 |
<table class="tbl click" id="ov-board">
|
| 132 |
+
<thead><tr><th>Model</th><th class="ov-oth" title="The 50 eval weeks by outcome">Outcomes</th><th class="num">Mean</th>${TIERS.map((t) => `<th class="num opt" title="${escapeHtml(TIER_TIPS[t])}">${cap(t)}</th>`).join("")}<th class="num" title="Episodes that ended with submit_plan">Submitted</th><th class="num" title="Plans that break no rule">Valid</th><th class="num" title="Plans that match the proven optimum (reward 1.0)">Optimal</th></tr></thead>
|
| 133 |
+
<tbody>${Array.from({ length: 6 }, () => `<tr class="skel">${"<td><i></i></td>".repeat(10)}</tr>`).join("")}</tbody>
|
| 134 |
</table>
|
| 135 |
+
<div class="ov-legend">${OUTCOMES.map((o) => `<span><i class="oc-${o.k}"></i>${o.label}</span>`).join("")}</div>
|
| 136 |
<p class="muted small ov-note" id="ov-board-note">Mean reward per model and tier. Click a model to list its rollouts; each one replays in 3D.</p>
|
| 137 |
</section>
|
| 138 |
+
<section class="ov-grid-sec" hidden>
|
| 139 |
+
<div class="sec-head"><h2>Every rollout</h2><span class="muted small">one square per model and eval week, coloured by outcome (fainter = lower reward) · click a square to replay it in 3D</span></div>
|
| 140 |
+
<div class="ov-mx" id="ov-mx"></div>
|
| 141 |
+
</section>
|
| 142 |
<section class="ov-eps" hidden>
|
| 143 |
<div class="sec-head"><h2 id="ov-eps-h">Rollouts</h2><span class="muted small" id="ov-eps-note"></span></div>
|
| 144 |
<table class="tbl click" id="ov-eps"><thead></thead><tbody></tbody></table>
|
|
|
|
| 219 |
|
| 220 |
const tbody = app.querySelector("#ov-board tbody");
|
| 221 |
const noRollouts = () => {
|
| 222 |
+
tbody.innerHTML = `<tr><td colspan="10" class="muted">No eval rollouts on this server.${explorer ? "" : ` They are in the <a href="${EVAL_VIEWER}" target="_blank" rel="noopener">eval Space ↗</a>.`}</td></tr>`;
|
| 223 |
app.querySelector("#ov-board-note").hidden = true;
|
| 224 |
if (explorer) return;
|
| 225 |
for (const a of app.querySelectorAll('a[href^="#/run/"]')) { // the showcase rollout: watch it in the eval Space
|
|
|
|
| 239 |
if (!isCurrent()) return;
|
| 240 |
if (!rows.length) return noRollouts();
|
| 241 |
tbody.innerHTML = rows.map((r) => `<tr data-row="${escapeHtml(r.model)}" title="List ${escapeHtml(r.name)}'s rollouts">
|
| 242 |
+
<td>${escapeHtml(r.name)}</td><td class="ov-otd">${outcomeBar(r)}</td><td class="num"><b>${fmtNum(r.mean, 3)}</b></td>
|
| 243 |
${TIERS.map((t) => `<td class="num opt">${r.tiers[t] == null ? "–" : fmtNum(r.tiers[t], 2)}</td>`).join("")}
|
| 244 |
<td class="num">${r.submitted}/${r.n}</td><td class="num">${r.valid}/${r.n}</td><td class="num">${r.optimal}/${r.n}</td></tr>`).join("");
|
| 245 |
|
|
|
|
| 247 |
for (const e of rows[0].eps) perTier.set(e.difficulty, (perTier.get(e.difficulty) || 0) + 1);
|
| 248 |
app.querySelector("#ov-board-note").textContent = `Mean reward per model and tier (${TIERS.filter((t) => perTier.has(t)).map((t) => `${t} ${perTier.get(t)}`).join(", ")} tasks). Click a model to list its rollouts; each one replays in 3D.`;
|
| 249 |
|
| 250 |
+
// Every rollout at a glance: models down, eval weeks across (grouped by tier), each square a link to its 3D replay.
|
| 251 |
+
{
|
| 252 |
+
const tasks = [...new Map(rows.flatMap((r) => r.eps).map((e) => [e.task_id, e])).values()]
|
| 253 |
+
.sort((a, b) => TIERS.indexOf(a.difficulty) - TIERS.indexOf(b.difficulty) || a.task_id.localeCompare(b.task_id));
|
| 254 |
+
const byKey = new Map(rows.flatMap((r) => r.eps.map((e) => [`${r.model}|${e.task_id}`, e])));
|
| 255 |
+
const groups = TIERS.map((t) => ({ t, tasks: tasks.filter((e) => e.difficulty === t) })).filter((g) => g.tasks.length);
|
| 256 |
+
const cell = (r, task) => {
|
| 257 |
+
const e = byKey.get(`${r.model}|${task.task_id}`);
|
| 258 |
+
if (!e) return '<span class="mx-c mx-missing"></span>';
|
| 259 |
+
const oc = outcomeOf(e);
|
| 260 |
+
const op = oc === "valid" ? (0.35 + 0.65 * Math.max(0, ((e.reward || 0) - 0.2) / 0.8)).toFixed(2) : "1";
|
| 261 |
+
return `<a class="mx-c oc-${oc}" style="opacity:${op}" href="${runHref(r.model, task.task_id)}" title="${escapeHtml(r.name)} · ${escapeHtml(task.task_id)} · ${task.ships} ships · ${oc === "none" ? "no plan" : `reward ${fmtNum(e.reward, 2)}`}"></a>`;
|
| 262 |
+
};
|
| 263 |
+
app.querySelector("#ov-mx").innerHTML = `<div class="mx-row mx-head"><span class="mx-name"></span>${groups.map((g) => `<span class="mx-g" style="--n:${g.tasks.length}" title="${escapeHtml(TIER_TIPS[g.t])}">${cap(g.t)} · ${g.tasks.length}</span>`).join("")}</div>` +
|
| 264 |
+
rows.map((r) => `<div class="mx-row"><span class="mx-name">${escapeHtml(r.name)}</span>${groups.map((g) => `<span class="mx-g" style="--n:${g.tasks.length}">${g.tasks.map((t) => cell(r, t)).join("")}</span>`).join("")}<span class="mx-mean">${fmtNum(r.mean, 2)}</span></div>`).join("");
|
| 265 |
+
app.querySelector(".ov-grid-sec").hidden = false;
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
// The selected model's rollouts, under the board (#/?model=<id> keeps the choice in shared links).
|
| 269 |
const epsSec = app.querySelector(".ov-eps");
|
| 270 |
let epsTable = null;
|
openenv/berth_openenv/web/play.js
CHANGED
|
@@ -1297,7 +1297,22 @@ export async function playPage({ app, params, isCurrent, setTeardown, sceneMod }
|
|
| 1297 |
|
| 1298 |
onConnStatus(session.status);
|
| 1299 |
bindShell();
|
| 1300 |
-
// #/play?
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1301 |
openPicker().then(() => {
|
| 1302 |
if (params.get("start") !== "1" || !isCurrent() || st.phase === "episode") return;
|
| 1303 |
const t = pickerTasks.find((x) => x.task_id === st.selected);
|
|
|
|
| 1297 |
|
| 1298 |
onConnStatus(session.status);
|
| 1299 |
bindShell();
|
| 1300 |
+
// #/play?split=<s>&index=<i>&start=1 starts that task's episode straight away, without the picker (OpenEnv's /web
|
| 1301 |
+
// tab and embeds use it); the one task comes from the Task API, so a 1,050-task split is never listed first.
|
| 1302 |
+
const autoIndex = params.get("index");
|
| 1303 |
+
if (params.get("start") === "1" && st.split && autoIndex !== null && autoIndex !== "") {
|
| 1304 |
+
$("#pa-main").innerHTML = '<div class="pa-empty muted">Starting an episode…</div>';
|
| 1305 |
+
taskApi("task", { split: st.split, index: Number(autoIndex) })
|
| 1306 |
+
.then((d) => {
|
| 1307 |
+
const t = d.task || d;
|
| 1308 |
+
if (!isCurrent() || st.phase === "episode") return;
|
| 1309 |
+
st.selected = t.task_id;
|
| 1310 |
+
return start({ task_id: t.task_id }, t);
|
| 1311 |
+
})
|
| 1312 |
+
.catch(() => isCurrent() && openPicker());
|
| 1313 |
+
return;
|
| 1314 |
+
}
|
| 1315 |
+
// #/play?task=<id>&start=1 (older links): pick it from the split list, then start
|
| 1316 |
openPicker().then(() => {
|
| 1317 |
if (params.get("start") !== "1" || !isCurrent() || st.phase === "episode") return;
|
| 1318 |
const t = pickerTasks.find((x) => x.task_id === st.selected);
|
openenv/berth_openenv/web/style.css
CHANGED
|
@@ -647,3 +647,25 @@ a.ext { font-size: 12px; white-space: nowrap; }
|
|
| 647 |
#ov-eps td { white-space: nowrap; }
|
| 648 |
.board-sec { overflow-x: auto; }
|
| 649 |
@media (max-width: 700px) { html.explorer .crumbs { display: none; } }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 647 |
#ov-eps td { white-space: nowrap; }
|
| 648 |
.board-sec { overflow-x: auto; }
|
| 649 |
@media (max-width: 700px) { html.explorer .crumbs { display: none; } }
|
| 650 |
+
|
| 651 |
+
/* eval visuals: outcome bars and the rollout grid */
|
| 652 |
+
.ov-oth { width: 30%; }
|
| 653 |
+
.ov-otd { padding-top: 6px !important; }
|
| 654 |
+
.ov-obar { display: flex; height: 12px; border-radius: 2px; overflow: hidden; background: var(--line2); min-width: 110px; }
|
| 655 |
+
.ov-obar i { display: block; height: 100%; }
|
| 656 |
+
.oc-optimal { background: var(--ok); }
|
| 657 |
+
.oc-valid { background: color-mix(in srgb, var(--ok) 45%, var(--bg)); }
|
| 658 |
+
.oc-broken { background: var(--red); }
|
| 659 |
+
.oc-none { background: var(--faint); opacity: .45; }
|
| 660 |
+
.ov-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; margin-top: 6px; font-size: 12px; color: var(--muted); }
|
| 661 |
+
.ov-legend i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 5px; vertical-align: -1px; }
|
| 662 |
+
.ov-mx { overflow-x: auto; padding-bottom: 4px; }
|
| 663 |
+
.mx-row { display: flex; align-items: center; gap: 10px; min-width: 760px; margin-bottom: 4px; }
|
| 664 |
+
.mx-name { flex: 0 0 120px; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
| 665 |
+
.mx-mean { flex: 0 0 34px; text-align: right; font-size: 12px; font-variant-numeric: tabular-nums; font-weight: 600; }
|
| 666 |
+
.mx-g { flex: var(--n) 1 0; display: grid; grid-template-columns: repeat(var(--n), 1fr); gap: 2px; min-width: 0; }
|
| 667 |
+
.mx-head .mx-g { display: block; font-size: 11px; color: var(--muted); border-bottom: 1px solid var(--line); padding-bottom: 2px; white-space: nowrap; overflow: hidden; }
|
| 668 |
+
.mx-c { display: block; aspect-ratio: 1; border-radius: 2px; min-width: 8px; }
|
| 669 |
+
.mx-c:hover { outline: 2px solid var(--fg); outline-offset: 1px; }
|
| 670 |
+
.mx-missing { background: transparent; border: 1px dashed var(--line); }
|
| 671 |
+
@media (max-width: 700px) { .ov-oth, .ov-otd { display: none; } }
|