kagabo commited on
Commit
617df40
·
verified ·
1 Parent(s): 57b5792

Add settings drawer and selectable Beni YOLOv8n model

Browse files
Files changed (5) hide show
  1. README.md +13 -0
  2. app.js +71 -14
  3. index.html +19 -9
  4. models/beni_yolov8n_water_meter.onnx +3 -0
  5. 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 MODEL_SIZE = 960;
2
- const MODEL_URL = "models/new_clean_yolo12n_raw_pascal_best.onnx";
 
 
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
- setSystemStatus("Loading AI model", "loading");
 
 
 
 
123
  ort.env.wasm.wasmPaths = "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.23.2/dist/";
124
  ort.env.wasm.numThreads = 1;
125
- session = await ort.InferenceSession.create(MODEL_URL, {
126
  executionProviders: ["wasm"],
127
  graphOptimizationLevel: "all",
128
  });
129
- setSystemStatus("AI model ready");
 
 
 
 
 
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 = MODEL_SIZE;
204
- workCanvas.height = MODEL_SIZE;
205
  const workContext = workCanvas.getContext("2d", { willReadFrequently: true });
206
- const scale = Math.min(MODEL_SIZE / image.naturalWidth, MODEL_SIZE / image.naturalHeight);
207
  const width = Math.round(image.naturalWidth * scale);
208
  const height = Math.round(image.naturalHeight * scale);
209
- const padX = Math.floor((MODEL_SIZE - width) / 2);
210
- const padY = Math.floor((MODEL_SIZE - height) / 2);
211
 
212
  workContext.fillStyle = "rgb(114, 114, 114)";
213
- workContext.fillRect(0, 0, MODEL_SIZE, MODEL_SIZE);
214
  workContext.drawImage(image, padX, padY, width, height);
215
 
216
- const pixels = workContext.getImageData(0, 0, MODEL_SIZE, MODEL_SIZE).data;
217
- const planeSize = MODEL_SIZE * MODEL_SIZE;
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, MODEL_SIZE, MODEL_SIZE]),
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 · WMRS</strong></div>
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
- <button id="runButton" class="analyze-button" type="button" disabled>
91
- <span class="button-icon"><svg viewBox="0 0 24 24"><path d="m13 2-2 8h7l-9 12 2-8H4l9-12Z"/></svg></span>
92
- <span><b id="runLabel">Preparing AI model</b><small>Run local computer vision</small></span>
93
- <svg class="button-arrow" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></svg>
94
- </button>
 
 
 
95
  <div id="modelStatus" class="model-status" role="status" aria-live="polite"><i class="loading"></i>Loading model for first use...</div>
96
 
97
- <article class="glass-panel settings-panel">
 
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 .analyze-button { order: 2; }
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; }