Spaces:
Running
Running
Add settings drawer and selectable Beni YOLOv8n model
Browse files- README.md +13 -0
- app.js +71 -14
- index.html +19 -9
- models/beni_yolov8n_water_meter.onnx +3 -0
- styles.css +22 -2
README.md
CHANGED
|
@@ -34,4 +34,17 @@ compact, and the primary Run Analysis action appears immediately after upload
|
|
| 34 |
and before advanced settings so the upload-to-predict workflow is visible
|
| 35 |
without scrolling on common desktop displays.
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
Source model: [`kagabo/hf_water_meter_models`](https://huggingface.co/kagabo/hf_water_meter_models)
|
|
|
|
| 34 |
and before advanced settings so the upload-to-predict workflow is visible
|
| 35 |
without scrolling on common desktop displays.
|
| 36 |
|
| 37 |
+
## Selectable models
|
| 38 |
+
|
| 39 |
+
The Reader can switch between two locally hosted ONNX models while keeping the
|
| 40 |
+
same 12-class decoder and post-processing pipeline:
|
| 41 |
+
|
| 42 |
+
- AquaVision YOLO12n (`960 x 960`) from `kagabo/hf_water_meter_models`
|
| 43 |
+
- Beni YOLOv8n (`640 x 640`) from `Beni4/rwanda-water-meter-reading-demo`
|
| 44 |
+
|
| 45 |
+
Both publish the same class order (`meter`, `window`, digits `0-9`) and declare
|
| 46 |
+
the AGPL-3.0 license in their model/Space metadata. The Performance tab is
|
| 47 |
+
explicitly labeled as AquaVision YOLO12n evidence and should not be interpreted
|
| 48 |
+
as validation evidence for Beni's model.
|
| 49 |
+
|
| 50 |
Source model: [`kagabo/hf_water_meter_models`](https://huggingface.co/kagabo/hf_water_meter_models)
|
app.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
| 1 |
-
const
|
| 2 |
-
|
|
|
|
|
|
|
| 3 |
const RECOVERY_CONFIDENCE_FLOOR = 0.001;
|
| 4 |
const CLASS_NAMES = ["meter", "window", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9"];
|
| 5 |
const COLORS = ["#51a7ff", "#39e6c6", "#ffc66d", "#ffc66d", "#ffc66d", "#ffc66d", "#ffc66d", "#ffc66d", "#ffc66d", "#ffc66d", "#ffc66d", "#ffc66d"];
|
|
@@ -12,6 +14,10 @@ const elements = {
|
|
| 12 |
runButton: document.querySelector("#runButton"),
|
| 13 |
runLabel: document.querySelector("#runLabel"),
|
| 14 |
modelStatus: document.querySelector("#modelStatus"),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
headerStatus: document.querySelector("#headerStatus"),
|
| 16 |
headerStatusDot: document.querySelector("#headerStatusDot"),
|
| 17 |
canvasWrap: document.querySelector("#canvasWrap"),
|
|
@@ -42,6 +48,10 @@ const elements = {
|
|
| 42 |
historyList: document.querySelector("#historyList"),
|
| 43 |
clearHistoryButton: document.querySelector("#clearHistoryButton"),
|
| 44 |
applyRecommendedButton: document.querySelector("#applyRecommendedButton"),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
tabButtons: [...document.querySelectorAll("[data-tab]")],
|
| 46 |
tabPanels: [...document.querySelectorAll("[data-tab-panel]")],
|
| 47 |
toast: document.querySelector("#toast"),
|
|
@@ -49,6 +59,8 @@ const elements = {
|
|
| 49 |
|
| 50 |
const context = elements.canvas.getContext("2d");
|
| 51 |
let session = null;
|
|
|
|
|
|
|
| 52 |
let selectedImage = null;
|
| 53 |
let selectedObjectUrl = null;
|
| 54 |
let running = false;
|
|
@@ -69,6 +81,10 @@ function unknownThreshold() {
|
|
| 69 |
return Number(elements.unknownSlider.value) / 100;
|
| 70 |
}
|
| 71 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
function showToast(message) {
|
| 73 |
elements.toast.textContent = message;
|
| 74 |
elements.toast.classList.add("show");
|
|
@@ -103,6 +119,28 @@ function applyRecommendedSettings() {
|
|
| 103 |
showToast("Recommended settings applied: 10% / 45% / 40%");
|
| 104 |
}
|
| 105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
function setSystemStatus(message, state = "ready") {
|
| 107 |
elements.headerStatus.textContent = message;
|
| 108 |
elements.headerStatusDot.className = state;
|
|
@@ -118,15 +156,26 @@ function updateRunButton() {
|
|
| 118 |
}
|
| 119 |
|
| 120 |
async function loadModel() {
|
|
|
|
|
|
|
| 121 |
try {
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
ort.env.wasm.wasmPaths = "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.23.2/dist/";
|
| 124 |
ort.env.wasm.numThreads = 1;
|
| 125 |
-
|
| 126 |
executionProviders: ["wasm"],
|
| 127 |
graphOptimizationLevel: "all",
|
| 128 |
});
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
} catch (error) {
|
| 131 |
console.error(error);
|
| 132 |
setSystemStatus("Model failed to load", "error");
|
|
@@ -199,22 +248,23 @@ function clearImage() {
|
|
| 199 |
}
|
| 200 |
|
| 201 |
function prepareInput(image) {
|
|
|
|
| 202 |
const workCanvas = document.createElement("canvas");
|
| 203 |
-
workCanvas.width =
|
| 204 |
-
workCanvas.height =
|
| 205 |
const workContext = workCanvas.getContext("2d", { willReadFrequently: true });
|
| 206 |
-
const scale = Math.min(
|
| 207 |
const width = Math.round(image.naturalWidth * scale);
|
| 208 |
const height = Math.round(image.naturalHeight * scale);
|
| 209 |
-
const padX = Math.floor((
|
| 210 |
-
const padY = Math.floor((
|
| 211 |
|
| 212 |
workContext.fillStyle = "rgb(114, 114, 114)";
|
| 213 |
-
workContext.fillRect(0, 0,
|
| 214 |
workContext.drawImage(image, padX, padY, width, height);
|
| 215 |
|
| 216 |
-
const pixels = workContext.getImageData(0, 0,
|
| 217 |
-
const planeSize =
|
| 218 |
const input = new Float32Array(3 * planeSize);
|
| 219 |
for (let pixel = 0, offset = 0; pixel < planeSize; pixel += 1, offset += 4) {
|
| 220 |
input[pixel] = pixels[offset] / 255;
|
|
@@ -223,7 +273,7 @@ function prepareInput(image) {
|
|
| 223 |
}
|
| 224 |
|
| 225 |
return {
|
| 226 |
-
tensor: new ort.Tensor("float32", input, [1, 3,
|
| 227 |
scale,
|
| 228 |
padX,
|
| 229 |
padY,
|
|
@@ -579,7 +629,14 @@ elements.unknownSlider.addEventListener("input", () => {
|
|
| 579 |
}
|
| 580 |
});
|
| 581 |
elements.tabButtons.forEach((button) => button.addEventListener("click", () => activateTab(button.dataset.tab)));
|
|
|
|
| 582 |
elements.applyRecommendedButton.addEventListener("click", applyRecommendedSettings);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 583 |
elements.clearHistoryButton.addEventListener("click", () => {
|
| 584 |
localStorage.removeItem(HISTORY_KEY);
|
| 585 |
renderHistory();
|
|
|
|
| 1 |
+
const MODEL_CONFIGS = {
|
| 2 |
+
aquavision: { name: "YOLO12n · AquaVision", shortName: "AquaVision YOLO12n", url: "models/new_clean_yolo12n_raw_pascal_best.onnx", sourceUrl: "https://huggingface.co/kagabo/hf_water_meter_models", size: 960 },
|
| 3 |
+
beni: { name: "YOLOv8n · Beni", shortName: "Beni YOLOv8n", url: "models/beni_yolov8n_water_meter.onnx", sourceUrl: "https://huggingface.co/spaces/Beni4/rwanda-water-meter-reading-demo", size: 640 },
|
| 4 |
+
};
|
| 5 |
const RECOVERY_CONFIDENCE_FLOOR = 0.001;
|
| 6 |
const CLASS_NAMES = ["meter", "window", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9"];
|
| 7 |
const COLORS = ["#51a7ff", "#39e6c6", "#ffc66d", "#ffc66d", "#ffc66d", "#ffc66d", "#ffc66d", "#ffc66d", "#ffc66d", "#ffc66d", "#ffc66d", "#ffc66d"];
|
|
|
|
| 14 |
runButton: document.querySelector("#runButton"),
|
| 15 |
runLabel: document.querySelector("#runLabel"),
|
| 16 |
modelStatus: document.querySelector("#modelStatus"),
|
| 17 |
+
activeModelName: document.querySelector("#activeModelName"),
|
| 18 |
+
activeModelInput: document.querySelector("#activeModelInput"),
|
| 19 |
+
activeModelSource: document.querySelector("#activeModelSource"),
|
| 20 |
+
modelButtons: [...document.querySelectorAll("[data-model]")],
|
| 21 |
headerStatus: document.querySelector("#headerStatus"),
|
| 22 |
headerStatusDot: document.querySelector("#headerStatusDot"),
|
| 23 |
canvasWrap: document.querySelector("#canvasWrap"),
|
|
|
|
| 48 |
historyList: document.querySelector("#historyList"),
|
| 49 |
clearHistoryButton: document.querySelector("#clearHistoryButton"),
|
| 50 |
applyRecommendedButton: document.querySelector("#applyRecommendedButton"),
|
| 51 |
+
settingsToggleButton: document.querySelector("#settingsToggleButton"),
|
| 52 |
+
settingsCloseButton: document.querySelector("#settingsCloseButton"),
|
| 53 |
+
settingsPanel: document.querySelector("#settingsPanel"),
|
| 54 |
+
settingsBackdrop: document.querySelector("#settingsBackdrop"),
|
| 55 |
tabButtons: [...document.querySelectorAll("[data-tab]")],
|
| 56 |
tabPanels: [...document.querySelectorAll("[data-tab-panel]")],
|
| 57 |
toast: document.querySelector("#toast"),
|
|
|
|
| 59 |
|
| 60 |
const context = elements.canvas.getContext("2d");
|
| 61 |
let session = null;
|
| 62 |
+
let activeModelKey = "aquavision";
|
| 63 |
+
let modelLoadGeneration = 0;
|
| 64 |
let selectedImage = null;
|
| 65 |
let selectedObjectUrl = null;
|
| 66 |
let running = false;
|
|
|
|
| 81 |
return Number(elements.unknownSlider.value) / 100;
|
| 82 |
}
|
| 83 |
|
| 84 |
+
function activeModel() {
|
| 85 |
+
return MODEL_CONFIGS[activeModelKey];
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
function showToast(message) {
|
| 89 |
elements.toast.textContent = message;
|
| 90 |
elements.toast.classList.add("show");
|
|
|
|
| 119 |
showToast("Recommended settings applied: 10% / 45% / 40%");
|
| 120 |
}
|
| 121 |
|
| 122 |
+
function setSettingsOpen(open) {
|
| 123 |
+
elements.settingsPanel.classList.toggle("open", open);
|
| 124 |
+
elements.settingsBackdrop.classList.toggle("open", open);
|
| 125 |
+
elements.settingsPanel.setAttribute("aria-hidden", String(!open));
|
| 126 |
+
elements.settingsBackdrop.setAttribute("aria-hidden", String(!open));
|
| 127 |
+
elements.settingsToggleButton.setAttribute("aria-expanded", String(open));
|
| 128 |
+
document.body.classList.toggle("settings-open", open);
|
| 129 |
+
if (open) elements.settingsCloseButton.focus();
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
async function switchModel(modelKey) {
|
| 133 |
+
if (!MODEL_CONFIGS[modelKey] || modelKey === activeModelKey) return;
|
| 134 |
+
activeModelKey = modelKey;
|
| 135 |
+
elements.modelButtons.forEach((button) => button.classList.toggle("active", button.dataset.model === modelKey));
|
| 136 |
+
elements.activeModelName.textContent = activeModel().name;
|
| 137 |
+
elements.activeModelInput.textContent = `${activeModel().size} x ${activeModel().size}`;
|
| 138 |
+
elements.activeModelSource.href = activeModel().sourceUrl;
|
| 139 |
+
resetResults();
|
| 140 |
+
await loadModel();
|
| 141 |
+
showToast(`${activeModel().shortName} selected`);
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
function setSystemStatus(message, state = "ready") {
|
| 145 |
elements.headerStatus.textContent = message;
|
| 146 |
elements.headerStatusDot.className = state;
|
|
|
|
| 156 |
}
|
| 157 |
|
| 158 |
async function loadModel() {
|
| 159 |
+
const generation = ++modelLoadGeneration;
|
| 160 |
+
const model = activeModel();
|
| 161 |
try {
|
| 162 |
+
const previousSession = session;
|
| 163 |
+
session = null;
|
| 164 |
+
updateRunButton();
|
| 165 |
+
setSystemStatus(`Loading ${model.shortName}`, "loading");
|
| 166 |
+
if (previousSession?.release) await previousSession.release();
|
| 167 |
ort.env.wasm.wasmPaths = "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.23.2/dist/";
|
| 168 |
ort.env.wasm.numThreads = 1;
|
| 169 |
+
const nextSession = await ort.InferenceSession.create(model.url, {
|
| 170 |
executionProviders: ["wasm"],
|
| 171 |
graphOptimizationLevel: "all",
|
| 172 |
});
|
| 173 |
+
if (generation !== modelLoadGeneration) {
|
| 174 |
+
if (nextSession.release) await nextSession.release();
|
| 175 |
+
return;
|
| 176 |
+
}
|
| 177 |
+
session = nextSession;
|
| 178 |
+
setSystemStatus(`${model.shortName} ready`);
|
| 179 |
} catch (error) {
|
| 180 |
console.error(error);
|
| 181 |
setSystemStatus("Model failed to load", "error");
|
|
|
|
| 248 |
}
|
| 249 |
|
| 250 |
function prepareInput(image) {
|
| 251 |
+
const modelSize = activeModel().size;
|
| 252 |
const workCanvas = document.createElement("canvas");
|
| 253 |
+
workCanvas.width = modelSize;
|
| 254 |
+
workCanvas.height = modelSize;
|
| 255 |
const workContext = workCanvas.getContext("2d", { willReadFrequently: true });
|
| 256 |
+
const scale = Math.min(modelSize / image.naturalWidth, modelSize / image.naturalHeight);
|
| 257 |
const width = Math.round(image.naturalWidth * scale);
|
| 258 |
const height = Math.round(image.naturalHeight * scale);
|
| 259 |
+
const padX = Math.floor((modelSize - width) / 2);
|
| 260 |
+
const padY = Math.floor((modelSize - height) / 2);
|
| 261 |
|
| 262 |
workContext.fillStyle = "rgb(114, 114, 114)";
|
| 263 |
+
workContext.fillRect(0, 0, modelSize, modelSize);
|
| 264 |
workContext.drawImage(image, padX, padY, width, height);
|
| 265 |
|
| 266 |
+
const pixels = workContext.getImageData(0, 0, modelSize, modelSize).data;
|
| 267 |
+
const planeSize = modelSize * modelSize;
|
| 268 |
const input = new Float32Array(3 * planeSize);
|
| 269 |
for (let pixel = 0, offset = 0; pixel < planeSize; pixel += 1, offset += 4) {
|
| 270 |
input[pixel] = pixels[offset] / 255;
|
|
|
|
| 273 |
}
|
| 274 |
|
| 275 |
return {
|
| 276 |
+
tensor: new ort.Tensor("float32", input, [1, 3, modelSize, modelSize]),
|
| 277 |
scale,
|
| 278 |
padX,
|
| 279 |
padY,
|
|
|
|
| 629 |
}
|
| 630 |
});
|
| 631 |
elements.tabButtons.forEach((button) => button.addEventListener("click", () => activateTab(button.dataset.tab)));
|
| 632 |
+
elements.modelButtons.forEach((button) => button.addEventListener("click", () => switchModel(button.dataset.model)));
|
| 633 |
elements.applyRecommendedButton.addEventListener("click", applyRecommendedSettings);
|
| 634 |
+
elements.settingsToggleButton.addEventListener("click", () => setSettingsOpen(true));
|
| 635 |
+
elements.settingsCloseButton.addEventListener("click", () => setSettingsOpen(false));
|
| 636 |
+
elements.settingsBackdrop.addEventListener("click", () => setSettingsOpen(false));
|
| 637 |
+
document.addEventListener("keydown", (event) => {
|
| 638 |
+
if (event.key === "Escape" && elements.settingsPanel.classList.contains("open")) setSettingsOpen(false);
|
| 639 |
+
});
|
| 640 |
elements.clearHistoryButton.addEventListener("click", () => {
|
| 641 |
localStorage.removeItem(HISTORY_KEY);
|
| 642 |
renderHistory();
|
index.html
CHANGED
|
@@ -51,11 +51,16 @@
|
|
| 51 |
<div class="model-card" aria-label="Model information">
|
| 52 |
<div class="model-card-head">
|
| 53 |
<span class="model-orbit"><i></i></span>
|
| 54 |
-
<div><small>ACTIVE MODEL</small><strong>YOLO12n ·
|
| 55 |
<span class="live-tag">LIVE</span>
|
| 56 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
<dl>
|
| 58 |
-
<div><dt>Input</dt><dd>960 x 960</dd></div>
|
| 59 |
<div><dt>Classes</dt><dd>12</dd></div>
|
| 60 |
<div><dt>Runtime</dt><dd>ONNX Web</dd></div>
|
| 61 |
</dl>
|
|
@@ -87,17 +92,22 @@
|
|
| 87 |
</div>
|
| 88 |
</article>
|
| 89 |
|
| 90 |
-
<
|
| 91 |
-
<
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
| 95 |
<div id="modelStatus" class="model-status" role="status" aria-live="polite"><i class="loading"></i>Loading model for first use...</div>
|
| 96 |
|
| 97 |
-
<
|
|
|
|
| 98 |
<div class="section-title compact">
|
| 99 |
<span class="step-number">02</span>
|
| 100 |
<div><small>AI CONTROL</small><h2>Detection settings</h2></div>
|
|
|
|
| 101 |
</div>
|
| 102 |
<label class="range-control">
|
| 103 |
<span><b>Confidence threshold</b><output id="confidenceValue">10%</output></span>
|
|
@@ -184,7 +194,7 @@
|
|
| 184 |
<section class="performance-section" id="model-performance">
|
| 185 |
<div class="performance-heading">
|
| 186 |
<div>
|
| 187 |
-
<div class="kicker"><span></span> VALIDATION EVIDENCE</div>
|
| 188 |
<h2>How well does the model perform?</h2>
|
| 189 |
<p>Aggregate object-detection results from the final clean YOLO12n model on the held-out 52-image combat test set.</p>
|
| 190 |
</div>
|
|
|
|
| 51 |
<div class="model-card" aria-label="Model information">
|
| 52 |
<div class="model-card-head">
|
| 53 |
<span class="model-orbit"><i></i></span>
|
| 54 |
+
<div><small>ACTIVE MODEL</small><strong id="activeModelName">YOLO12n · AquaVision</strong></div>
|
| 55 |
<span class="live-tag">LIVE</span>
|
| 56 |
</div>
|
| 57 |
+
<div class="model-selector" role="group" aria-label="Choose inference model">
|
| 58 |
+
<button class="model-option active" type="button" data-model="aquavision"><b>AquaVision</b><small>YOLO12n</small></button>
|
| 59 |
+
<button class="model-option" type="button" data-model="beni"><b>Beni</b><small>YOLOv8n</small></button>
|
| 60 |
+
</div>
|
| 61 |
+
<a id="activeModelSource" class="active-model-source" href="https://huggingface.co/kagabo/hf_water_meter_models" target="_blank" rel="noreferrer">Open active model source ↗</a>
|
| 62 |
<dl>
|
| 63 |
+
<div><dt>Input</dt><dd id="activeModelInput">960 x 960</dd></div>
|
| 64 |
<div><dt>Classes</dt><dd>12</dd></div>
|
| 65 |
<div><dt>Runtime</dt><dd>ONNX Web</dd></div>
|
| 66 |
</dl>
|
|
|
|
| 92 |
</div>
|
| 93 |
</article>
|
| 94 |
|
| 95 |
+
<div class="primary-actions">
|
| 96 |
+
<button id="runButton" class="analyze-button" type="button" disabled>
|
| 97 |
+
<span class="button-icon"><svg viewBox="0 0 24 24"><path d="m13 2-2 8h7l-9 12 2-8H4l9-12Z"/></svg></span>
|
| 98 |
+
<span><b id="runLabel">Preparing AI model</b><small>Run local computer vision</small></span>
|
| 99 |
+
<svg class="button-arrow" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></svg>
|
| 100 |
+
</button>
|
| 101 |
+
<button id="settingsToggleButton" class="settings-toggle" type="button" aria-controls="settingsPanel" aria-expanded="false"><span>⚙</span><b>Tune settings</b></button>
|
| 102 |
+
</div>
|
| 103 |
<div id="modelStatus" class="model-status" role="status" aria-live="polite"><i class="loading"></i>Loading model for first use...</div>
|
| 104 |
|
| 105 |
+
<div id="settingsBackdrop" class="settings-backdrop" aria-hidden="true"></div>
|
| 106 |
+
<article id="settingsPanel" class="glass-panel settings-panel" aria-hidden="true" aria-label="Detection settings">
|
| 107 |
<div class="section-title compact">
|
| 108 |
<span class="step-number">02</span>
|
| 109 |
<div><small>AI CONTROL</small><h2>Detection settings</h2></div>
|
| 110 |
+
<button id="settingsCloseButton" class="drawer-close" type="button" aria-label="Close settings">×</button>
|
| 111 |
</div>
|
| 112 |
<label class="range-control">
|
| 113 |
<span><b>Confidence threshold</b><output id="confidenceValue">10%</output></span>
|
|
|
|
| 194 |
<section class="performance-section" id="model-performance">
|
| 195 |
<div class="performance-heading">
|
| 196 |
<div>
|
| 197 |
+
<div class="kicker"><span></span> AQUAVISION YOLO12N VALIDATION EVIDENCE</div>
|
| 198 |
<h2>How well does the model perform?</h2>
|
| 199 |
<p>Aggregate object-detection results from the final clean YOLO12n model on the held-out 52-image combat test set.</p>
|
| 200 |
</div>
|
models/beni_yolov8n_water_meter.onnx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:fdf3016d0081d2a6eb0885d643790baf7620e31e972de91da13c77d1570d0bd3
|
| 3 |
+
size 12247048
|
styles.css
CHANGED
|
@@ -63,6 +63,13 @@ svg { width: 20px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-l
|
|
| 63 |
#tab-reader .model-orbit { width: 30px; height: 30px; }
|
| 64 |
#tab-reader .model-card dl { margin-top: 9px; }
|
| 65 |
#tab-reader .model-card dl div { gap: 2px; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
#tab-reader .upload-panel, #tab-reader .settings-panel { padding: 15px; }
|
| 67 |
#tab-reader .upload-panel .section-title { margin-bottom: 10px; }
|
| 68 |
#tab-reader .drop-zone { min-height: 138px; gap: 4px; padding: 13px; }
|
|
@@ -72,10 +79,21 @@ svg { width: 20px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-l
|
|
| 72 |
#tab-reader .settings-panel { gap: 11px; }
|
| 73 |
#tab-reader .range-control { gap: 5px; }
|
| 74 |
#tab-reader .range-control small { line-height: 1.3; }
|
| 75 |
-
#tab-reader .
|
| 76 |
#tab-reader .model-status { order: 3; margin-top: -7px; }
|
| 77 |
-
#tab-reader .settings-panel { order: 4; }
|
| 78 |
#tab-reader .canvas-wrap { min-height: 380px; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
.intro { display: grid; grid-template-columns: minmax(0, 1fr) 350px; align-items: end; gap: 48px; margin-bottom: 38px; }
|
| 80 |
.kicker { display: flex; align-items: center; gap: 9px; margin-bottom: 15px; color: var(--aqua); font: 800 10px ui-monospace, monospace; letter-spacing: .18em; }
|
| 81 |
.kicker span { width: 22px; height: 1px; background: var(--aqua); box-shadow: 0 0 8px var(--aqua); }
|
|
@@ -344,6 +362,8 @@ footer strong { color: #c8d4de; }
|
|
| 344 |
.tech-strip { grid-template-columns: 1fr; }
|
| 345 |
.tech-strip > div { border-left: 0; border-top: 1px solid var(--line); }
|
| 346 |
.reading-flow { grid-template-columns: 1fr; }
|
|
|
|
|
|
|
| 347 |
.tech-strip > div:first-child { border-top: 0; }
|
| 348 |
footer { width: calc(100% - 20px); align-items: flex-start; gap: 18px; }
|
| 349 |
footer > div:first-child { flex-direction: column; gap: 3px; }
|
|
|
|
| 63 |
#tab-reader .model-orbit { width: 30px; height: 30px; }
|
| 64 |
#tab-reader .model-card dl { margin-top: 9px; }
|
| 65 |
#tab-reader .model-card dl div { gap: 2px; }
|
| 66 |
+
.model-selector { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; margin-top: 9px; }
|
| 67 |
+
.model-option { display: flex; align-items: center; justify-content: center; gap: 6px; padding: 6px 8px; border: 1px solid var(--line); border-radius: 8px; color: var(--muted); background: rgba(255,255,255,.02); cursor: pointer; }
|
| 68 |
+
.model-option b { font-size: 8px; }
|
| 69 |
+
.model-option small { font-size: 7px; }
|
| 70 |
+
.model-option.active { color: #031713; border-color: var(--aqua); background: var(--aqua); }
|
| 71 |
+
.active-model-source { display: block; margin-top: 6px; color: var(--muted); font-size: 7px; text-align: right; text-decoration: none; }
|
| 72 |
+
.active-model-source:hover { color: var(--aqua); }
|
| 73 |
#tab-reader .upload-panel, #tab-reader .settings-panel { padding: 15px; }
|
| 74 |
#tab-reader .upload-panel .section-title { margin-bottom: 10px; }
|
| 75 |
#tab-reader .drop-zone { min-height: 138px; gap: 4px; padding: 13px; }
|
|
|
|
| 79 |
#tab-reader .settings-panel { gap: 11px; }
|
| 80 |
#tab-reader .range-control { gap: 5px; }
|
| 81 |
#tab-reader .range-control small { line-height: 1.3; }
|
| 82 |
+
#tab-reader .primary-actions { order: 2; }
|
| 83 |
#tab-reader .model-status { order: 3; margin-top: -7px; }
|
|
|
|
| 84 |
#tab-reader .canvas-wrap { min-height: 380px; }
|
| 85 |
+
.primary-actions { display: grid; grid-template-columns: minmax(0, 1fr) 112px; gap: 9px; }
|
| 86 |
+
.settings-toggle { display: grid; place-items: center; align-content: center; gap: 3px; padding: 8px; border: 1px solid var(--line-bright); border-radius: 14px; color: var(--aqua); background: rgba(57,230,198,.06); cursor: pointer; }
|
| 87 |
+
.settings-toggle span { font-size: 17px; line-height: 1; }
|
| 88 |
+
.settings-toggle b { font-size: 8px; }
|
| 89 |
+
.settings-toggle:hover { background: rgba(57,230,198,.12); }
|
| 90 |
+
.settings-backdrop { position: fixed; inset: 0; z-index: 54; visibility: hidden; background: rgba(2,8,15,.62); opacity: 0; backdrop-filter: blur(3px); transition: .2s ease; }
|
| 91 |
+
.settings-backdrop.open { visibility: visible; opacity: 1; }
|
| 92 |
+
#tab-reader .settings-panel { position: fixed; z-index: 55; top: 92px; right: max(20px, calc((100vw - 1240px) / 2)); width: min(370px, calc(100vw - 28px)); max-height: calc(100vh - 116px); overflow-y: auto; padding: 22px; visibility: hidden; opacity: 0; transform: translateX(24px); pointer-events: none; transition: .22s ease; }
|
| 93 |
+
#tab-reader .settings-panel.open { visibility: visible; opacity: 1; transform: none; pointer-events: auto; }
|
| 94 |
+
.drawer-close { width: 30px; height: 30px; display: grid; place-items: center; margin-left: auto; border: 1px solid var(--line); border-radius: 9px; color: var(--muted); background: rgba(255,255,255,.025); font-size: 19px; cursor: pointer; }
|
| 95 |
+
.drawer-close:hover { color: var(--text); border-color: var(--line-bright); }
|
| 96 |
+
body.settings-open { overflow: hidden; }
|
| 97 |
.intro { display: grid; grid-template-columns: minmax(0, 1fr) 350px; align-items: end; gap: 48px; margin-bottom: 38px; }
|
| 98 |
.kicker { display: flex; align-items: center; gap: 9px; margin-bottom: 15px; color: var(--aqua); font: 800 10px ui-monospace, monospace; letter-spacing: .18em; }
|
| 99 |
.kicker span { width: 22px; height: 1px; background: var(--aqua); box-shadow: 0 0 8px var(--aqua); }
|
|
|
|
| 362 |
.tech-strip { grid-template-columns: 1fr; }
|
| 363 |
.tech-strip > div { border-left: 0; border-top: 1px solid var(--line); }
|
| 364 |
.reading-flow { grid-template-columns: 1fr; }
|
| 365 |
+
.primary-actions { grid-template-columns: 1fr 96px; }
|
| 366 |
+
#tab-reader .settings-panel { inset: auto 0 0; width: 100%; max-height: 82dvh; border-radius: 20px 20px 0 0; transform: translateY(24px); }
|
| 367 |
.tech-strip > div:first-child { border-top: 0; }
|
| 368 |
footer { width: calc(100% - 20px); align-items: flex-start; gap: 18px; }
|
| 369 |
footer > div:first-child { flex-direction: column; gap: 3px; }
|