Spaces:
Running
Running
feat(ui): field-info tips and retire landscape gate
Browse filesAdd short English tap tips on editable controls for mobile, and remove the false “Better in landscape” overlay so portrait stays the default phone layout.
Co-authored-by: Cursor <cursoragent@cursor.com>
- .agents/skills/dev-protocol/lessons-learned.md +12 -4
- CHANGELOG.md +2 -0
- src/main.js +4 -0
- src/style.css +133 -1
- src/ui/ComparePanel.js +3 -2
- src/ui/LandscapeGate.js +19 -66
- src/ui/SaeControls.js +12 -8
- src/ui/Sidebar.js +5 -3
- src/ui/ThreadSliders.js +6 -5
- src/ui/TouchControls.js +2 -0
- src/ui/VisualizationControls.js +12 -8
- src/ui/fieldInfo.js +193 -0
- tests/LandscapeGate.test.js +4 -7
- tests/ThreadSliders.test.js +7 -0
- tests/TouchControls.test.js +9 -0
- tests/fieldInfo.test.js +69 -0
- tests/visualizationControls.test.js +3 -0
.agents/skills/dev-protocol/lessons-learned.md
CHANGED
|
@@ -187,10 +187,11 @@ User-editable hex anchors replace the former fixed dual ramp (no product mid-sto
|
|
| 187 |
- **Solución Obligatoria**: `createWideRibbonMesh` (quad strip a lo largo del centerline). Sin Points. Sin montar plano base en Instancer.
|
| 188 |
- **Invariante**: RIBBONS ≠ POINTS; no reintroducir base plane bajo ribbons sin opacificar/`depthWrite` conscientes.
|
| 189 |
|
| 190 |
-
### 4.4. Landscape Gate
|
| 191 |
-
- **
|
| 192 |
-
- **
|
| 193 |
-
- **
|
|
|
|
| 194 |
|
| 195 |
---
|
| 196 |
|
|
@@ -255,6 +256,13 @@ Portable findings from VHectorLab 3D `v2.1.0` — apply if the older app shares
|
|
| 255 |
|
| 256 |
---
|
| 257 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
## 5. Protocolo de Mantenimiento de Lecciones Aprendidas
|
| 259 |
|
| 260 |
1. **Consulta Obligatoria**: El agente **DEBE** leer este archivo al iniciar cualquier tarea de implementación, diseño de shaders, navegación o refactorización.
|
|
|
|
| 187 |
- **Solución Obligatoria**: `createWideRibbonMesh` (quad strip a lo largo del centerline). Sin Points. Sin montar plano base en Instancer.
|
| 188 |
- **Invariante**: RIBBONS ≠ POINTS; no reintroducir base plane bajo ribbons sin opacificar/`depthWrite` conscientes.
|
| 189 |
|
| 190 |
+
### 4.4. Landscape Gate — RETIRED (portrait preferred)
|
| 191 |
+
- **Antes**: Soft overlay en phone portrait (“Better in landscape”).
|
| 192 |
+
- **Decisión**: Retirado — en producto real se ve **mejor en portrait** que de costado; el cartel era mentira UX.
|
| 193 |
+
- **Solución Obligatoria**: `shouldShowLandscapeGate` → siempre `false`; clase no monta copy. No reintroducir nudge landscape sin pedido explícito.
|
| 194 |
+
- **Invariante**: phone = portrait-first; no orientation lock; render loop nunca pausa por orientación.
|
| 195 |
|
| 196 |
---
|
| 197 |
|
|
|
|
| 256 |
|
| 257 |
---
|
| 258 |
|
| 259 |
+
### 4.13. Field-info tips ("i") — tap, not hover-only
|
| 260 |
+
- **Problema**: Native `title=` tooltips fail on phone (long-press unreliable); editable fields had almost no per-control help.
|
| 261 |
+
- **Solución Obligatoria**: Deep module `fieldInfo.js` — `FIELD_INFO` short EN catalog (≤28 chars), `infoTipMarkup`, `wireFieldInfo` (one tip at a time, outside/Escape closes, viewport-clamped). Wire on Arithmetic / Compare / SAE params / Spatial sliders / Visualization. Touch: `.field-info-btn` / `.field-info-tip` in `isUiTouchTarget`.
|
| 262 |
+
- **Invariante**: help copy = English, short, mobile-first; do not rely on hover-only `title` for field meaning.
|
| 263 |
+
|
| 264 |
+
---
|
| 265 |
+
|
| 266 |
## 5. Protocolo de Mantenimiento de Lecciones Aprendidas
|
| 267 |
|
| 268 |
1. **Consulta Obligatoria**: El agente **DEBE** leer este archivo al iniciar cualquier tarea de implementación, diseño de shaders, navegación o refactorización.
|
CHANGELOG.md
CHANGED
|
@@ -5,10 +5,12 @@ All notable changes to VHectorLab 3D will be documented in this file.
|
|
| 5 |
## [Unreleased]
|
| 6 |
|
| 7 |
### Added
|
|
|
|
| 8 |
- **HF Space cpu-basic demo path** (`feat/hf-space-cpu-demo`): Docker torch CPU (`UV_TORCH_BACKEND=cpu`), vocab embeddings NPZ at image build, `/health.device`, navbar `ONLINE (model · device)`, ARITHMETIC `localStorage` persistence (`vl3d.arithmetic.*`), setup option 7 smoke-run + option 8 `hf` create Space (docker / cpu-basic) + push, README Space YAML frontmatter.
|
| 9 |
|
| 10 |
### Changed
|
| 11 |
- **Repo / local folder slug**: GitHub + working copy **`vhectorlab`** (was `VHectorLab-3D`). Product name **VHectorLab 3D** unchanged; npm/manifest id stays `vhectorlab-3d`.
|
|
|
|
| 12 |
|
| 13 |
### Fixed
|
| 14 |
- **`setup.sh` option 8**: defaults from `.env` (`HF_SPACE_ID`, `HF_SPACE_FORCE_PUSH=1`); skip create if Space exists; force-push to Space remote only (not GitHub) so Enter×N publishes over divergent predecessor history.
|
|
|
|
| 5 |
## [Unreleased]
|
| 6 |
|
| 7 |
### Added
|
| 8 |
+
- **Field-info tips ("i")** on every editable control (Arithmetic, Compare, SAE params, Spatial sliders, Visualization): short English tap tips, mobile-safe popover (not hover-only `title`).
|
| 9 |
- **HF Space cpu-basic demo path** (`feat/hf-space-cpu-demo`): Docker torch CPU (`UV_TORCH_BACKEND=cpu`), vocab embeddings NPZ at image build, `/health.device`, navbar `ONLINE (model · device)`, ARITHMETIC `localStorage` persistence (`vl3d.arithmetic.*`), setup option 7 smoke-run + option 8 `hf` create Space (docker / cpu-basic) + push, README Space YAML frontmatter.
|
| 10 |
|
| 11 |
### Changed
|
| 12 |
- **Repo / local folder slug**: GitHub + working copy **`vhectorlab`** (was `VHectorLab-3D`). Product name **VHectorLab 3D** unchanged; npm/manifest id stays `vhectorlab-3d`.
|
| 13 |
+
- **Landscape gate retired**: no more “Better in landscape” cartel — phone portrait is preferred.
|
| 14 |
|
| 15 |
### Fixed
|
| 16 |
- **`setup.sh` option 8**: defaults from `.env` (`HF_SPACE_ID`, `HF_SPACE_FORCE_PUSH=1`); skip create if Space exists; force-push to Space remote only (not GitHub) so Enter×N publishes over divergent predecessor history.
|
src/main.js
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
| 48 |
computeActivationMetrics,
|
| 49 |
formatSaeTrainProgress,
|
| 50 |
} from './ui/saeControlsDefaults.js';
|
|
|
|
| 51 |
import {
|
| 52 |
loadArithmeticSettings,
|
| 53 |
saveArithmeticSettings,
|
|
@@ -117,6 +118,9 @@ class VHectorLabApp {
|
|
| 117 |
}
|
| 118 |
);
|
| 119 |
|
|
|
|
|
|
|
|
|
|
| 120 |
// Left dock hosts Arithmetic OR Compare (shared collapsed state across MODE).
|
| 121 |
this.leftDock = new CollapsibleDock({
|
| 122 |
parent: this.appContainer,
|
|
|
|
| 48 |
computeActivationMetrics,
|
| 49 |
formatSaeTrainProgress,
|
| 50 |
} from './ui/saeControlsDefaults.js';
|
| 51 |
+
import { wireFieldInfo } from './ui/fieldInfo.js';
|
| 52 |
import {
|
| 53 |
loadArithmeticSettings,
|
| 54 |
saveArithmeticSettings,
|
|
|
|
| 118 |
}
|
| 119 |
);
|
| 120 |
|
| 121 |
+
// Tap/click "i" tips on editable fields (mobile-friendly; one tip at a time).
|
| 122 |
+
wireFieldInfo(document);
|
| 123 |
+
|
| 124 |
// Left dock hosts Arithmetic OR Compare (shared collapsed state across MODE).
|
| 125 |
this.leftDock = new CollapsibleDock({
|
| 126 |
parent: this.appContainer,
|
src/style.css
CHANGED
|
@@ -280,6 +280,15 @@ canvas {
|
|
| 280 |
flex-shrink: 0;
|
| 281 |
}
|
| 282 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
/* COMPARE: cosine-vs-anchor list — scroll only inside the list (§4.1 panel invariant) */
|
| 284 |
#compare-panel .compare-results {
|
| 285 |
flex: 1 1 auto;
|
|
@@ -492,11 +501,66 @@ canvas {
|
|
| 492 |
}
|
| 493 |
|
| 494 |
.input-group label {
|
|
|
|
|
|
|
|
|
|
| 495 |
font-size: 0.75rem;
|
| 496 |
font-weight: 600;
|
| 497 |
color: var(--text-muted);
|
| 498 |
}
|
| 499 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 500 |
.input-group.positive label .op { color: var(--accent-cyan); }
|
| 501 |
.input-group.negative label .op { color: var(--accent-red); }
|
| 502 |
|
|
@@ -562,6 +626,17 @@ canvas {
|
|
| 562 |
line-height: 1.2;
|
| 563 |
}
|
| 564 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 565 |
.btn-sae-toggle {
|
| 566 |
background: rgba(255, 255, 255, 0.06);
|
| 567 |
color: var(--text-main);
|
|
@@ -639,6 +714,13 @@ canvas {
|
|
| 639 |
gap: 2px;
|
| 640 |
}
|
| 641 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 642 |
.sae-params-panel input {
|
| 643 |
background: rgba(0, 0, 0, 0.35);
|
| 644 |
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
@@ -1227,7 +1309,7 @@ canvas {
|
|
| 1227 |
|
| 1228 |
#visualization-controls-container .viz-color-row label {
|
| 1229 |
font-size: 0.72rem;
|
| 1230 |
-
min-width:
|
| 1231 |
}
|
| 1232 |
|
| 1233 |
#visualization-controls-container .viz-color-hex {
|
|
@@ -1278,6 +1360,17 @@ canvas {
|
|
| 1278 |
color: var(--text-muted);
|
| 1279 |
}
|
| 1280 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1281 |
.slider-val {
|
| 1282 |
font-family: monospace;
|
| 1283 |
color: var(--accent-gold);
|
|
@@ -1298,11 +1391,18 @@ canvas {
|
|
| 1298 |
}
|
| 1299 |
|
| 1300 |
.viz-filter-label {
|
|
|
|
|
|
|
|
|
|
| 1301 |
font-size: 0.8rem;
|
| 1302 |
font-weight: 600;
|
| 1303 |
color: var(--text-muted);
|
| 1304 |
}
|
| 1305 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1306 |
.viz-segmented {
|
| 1307 |
display: flex;
|
| 1308 |
width: 100%;
|
|
@@ -1369,12 +1469,32 @@ canvas {
|
|
| 1369 |
}
|
| 1370 |
|
| 1371 |
.viz-color-row label {
|
|
|
|
|
|
|
|
|
|
| 1372 |
font-size: 0.78rem;
|
| 1373 |
font-weight: 700;
|
| 1374 |
color: var(--accent-gold);
|
| 1375 |
font-family: monospace;
|
| 1376 |
}
|
| 1377 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1378 |
.viz-color-swatch {
|
| 1379 |
width: 32px;
|
| 1380 |
height: 24px;
|
|
@@ -1961,5 +2081,17 @@ canvas {
|
|
| 1961 |
#compare-panel .compare-cosine-list {
|
| 1962 |
max-height: min(180px, 32dvh);
|
| 1963 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1964 |
}
|
| 1965 |
|
|
|
|
| 280 |
flex-shrink: 0;
|
| 281 |
}
|
| 282 |
|
| 283 |
+
.compare-dim-sort .field-label-text {
|
| 284 |
+
flex: 1 1 auto;
|
| 285 |
+
min-width: 0;
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
.compare-dim-sort .field-info-btn {
|
| 289 |
+
margin-left: auto;
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
/* COMPARE: cosine-vs-anchor list — scroll only inside the list (§4.1 panel invariant) */
|
| 293 |
#compare-panel .compare-results {
|
| 294 |
flex: 1 1 auto;
|
|
|
|
| 501 |
}
|
| 502 |
|
| 503 |
.input-group label {
|
| 504 |
+
display: flex;
|
| 505 |
+
align-items: center;
|
| 506 |
+
gap: 4px;
|
| 507 |
font-size: 0.75rem;
|
| 508 |
font-weight: 600;
|
| 509 |
color: var(--text-muted);
|
| 510 |
}
|
| 511 |
|
| 512 |
+
.field-label-text {
|
| 513 |
+
min-width: 0;
|
| 514 |
+
flex: 1 1 auto;
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
.field-info-btn {
|
| 518 |
+
flex: 0 0 auto;
|
| 519 |
+
width: 14px;
|
| 520 |
+
height: 14px;
|
| 521 |
+
padding: 0;
|
| 522 |
+
margin: 0;
|
| 523 |
+
border-radius: 50%;
|
| 524 |
+
border: 1px solid rgba(255, 255, 255, 0.28);
|
| 525 |
+
background: rgba(255, 255, 255, 0.08);
|
| 526 |
+
color: var(--text-muted);
|
| 527 |
+
font-size: 0.58rem;
|
| 528 |
+
font-weight: 700;
|
| 529 |
+
line-height: 1;
|
| 530 |
+
font-family: inherit;
|
| 531 |
+
cursor: pointer;
|
| 532 |
+
display: inline-flex;
|
| 533 |
+
align-items: center;
|
| 534 |
+
justify-content: center;
|
| 535 |
+
-webkit-tap-highlight-color: transparent;
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
.field-info-btn:hover,
|
| 539 |
+
.field-info-btn[aria-expanded="true"] {
|
| 540 |
+
color: var(--accent-cyan);
|
| 541 |
+
border-color: rgba(0, 204, 255, 0.55);
|
| 542 |
+
background: rgba(0, 200, 255, 0.12);
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
.field-info-tip {
|
| 546 |
+
position: fixed;
|
| 547 |
+
z-index: 120;
|
| 548 |
+
max-width: min(200px, calc(100vw - 16px));
|
| 549 |
+
padding: 6px 8px;
|
| 550 |
+
border-radius: 6px;
|
| 551 |
+
border: 1px solid var(--panel-border);
|
| 552 |
+
background: rgba(12, 16, 24, 0.96);
|
| 553 |
+
backdrop-filter: blur(12px);
|
| 554 |
+
-webkit-backdrop-filter: blur(12px);
|
| 555 |
+
color: var(--text-main);
|
| 556 |
+
font-size: 0.68rem;
|
| 557 |
+
font-weight: 500;
|
| 558 |
+
line-height: 1.3;
|
| 559 |
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
| 560 |
+
pointer-events: auto;
|
| 561 |
+
word-break: break-word;
|
| 562 |
+
}
|
| 563 |
+
|
| 564 |
.input-group.positive label .op { color: var(--accent-cyan); }
|
| 565 |
.input-group.negative label .op { color: var(--accent-red); }
|
| 566 |
|
|
|
|
| 626 |
line-height: 1.2;
|
| 627 |
}
|
| 628 |
|
| 629 |
+
.sae-toggle-with-info {
|
| 630 |
+
display: flex;
|
| 631 |
+
align-items: center;
|
| 632 |
+
gap: 4px;
|
| 633 |
+
min-width: 0;
|
| 634 |
+
}
|
| 635 |
+
|
| 636 |
+
.sae-toggle-with-info .btn-sae-toggle {
|
| 637 |
+
flex: 1 1 auto;
|
| 638 |
+
}
|
| 639 |
+
|
| 640 |
.btn-sae-toggle {
|
| 641 |
background: rgba(255, 255, 255, 0.06);
|
| 642 |
color: var(--text-main);
|
|
|
|
| 714 |
gap: 2px;
|
| 715 |
}
|
| 716 |
|
| 717 |
+
.sae-params-panel .field-label-text {
|
| 718 |
+
display: inline-flex;
|
| 719 |
+
align-items: center;
|
| 720 |
+
gap: 4px;
|
| 721 |
+
flex: 0 1 auto;
|
| 722 |
+
}
|
| 723 |
+
|
| 724 |
.sae-params-panel input {
|
| 725 |
background: rgba(0, 0, 0, 0.35);
|
| 726 |
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
|
|
| 1309 |
|
| 1310 |
#visualization-controls-container .viz-color-row label {
|
| 1311 |
font-size: 0.72rem;
|
| 1312 |
+
min-width: 2.4rem;
|
| 1313 |
}
|
| 1314 |
|
| 1315 |
#visualization-controls-container .viz-color-hex {
|
|
|
|
| 1360 |
color: var(--text-muted);
|
| 1361 |
}
|
| 1362 |
|
| 1363 |
+
.slider-header label {
|
| 1364 |
+
display: inline-flex;
|
| 1365 |
+
align-items: center;
|
| 1366 |
+
gap: 4px;
|
| 1367 |
+
min-width: 0;
|
| 1368 |
+
}
|
| 1369 |
+
|
| 1370 |
+
.slider-header .field-label-text {
|
| 1371 |
+
flex: 0 1 auto;
|
| 1372 |
+
}
|
| 1373 |
+
|
| 1374 |
.slider-val {
|
| 1375 |
font-family: monospace;
|
| 1376 |
color: var(--accent-gold);
|
|
|
|
| 1391 |
}
|
| 1392 |
|
| 1393 |
.viz-filter-label {
|
| 1394 |
+
display: inline-flex;
|
| 1395 |
+
align-items: center;
|
| 1396 |
+
gap: 4px;
|
| 1397 |
font-size: 0.8rem;
|
| 1398 |
font-weight: 600;
|
| 1399 |
color: var(--text-muted);
|
| 1400 |
}
|
| 1401 |
|
| 1402 |
+
.viz-filter-label .field-label-text {
|
| 1403 |
+
flex: 0 1 auto;
|
| 1404 |
+
}
|
| 1405 |
+
|
| 1406 |
.viz-segmented {
|
| 1407 |
display: flex;
|
| 1408 |
width: 100%;
|
|
|
|
| 1469 |
}
|
| 1470 |
|
| 1471 |
.viz-color-row label {
|
| 1472 |
+
display: inline-flex;
|
| 1473 |
+
align-items: center;
|
| 1474 |
+
gap: 4px;
|
| 1475 |
font-size: 0.78rem;
|
| 1476 |
font-weight: 700;
|
| 1477 |
color: var(--accent-gold);
|
| 1478 |
font-family: monospace;
|
| 1479 |
}
|
| 1480 |
|
| 1481 |
+
.viz-color-row label .field-label-text {
|
| 1482 |
+
flex: 0 0 auto;
|
| 1483 |
+
}
|
| 1484 |
+
|
| 1485 |
+
.viz-labels-row {
|
| 1486 |
+
display: flex;
|
| 1487 |
+
align-items: center;
|
| 1488 |
+
gap: 6px;
|
| 1489 |
+
margin-top: 2px;
|
| 1490 |
+
}
|
| 1491 |
+
|
| 1492 |
+
.viz-labels-row .viz-labels-toggle {
|
| 1493 |
+
flex: 1 1 auto;
|
| 1494 |
+
margin-top: 0;
|
| 1495 |
+
align-self: auto;
|
| 1496 |
+
}
|
| 1497 |
+
|
| 1498 |
.viz-color-swatch {
|
| 1499 |
width: 32px;
|
| 1500 |
height: 24px;
|
|
|
|
| 2081 |
#compare-panel .compare-cosine-list {
|
| 2082 |
max-height: min(180px, 32dvh);
|
| 2083 |
}
|
| 2084 |
+
|
| 2085 |
+
.field-info-btn {
|
| 2086 |
+
width: 18px;
|
| 2087 |
+
height: 18px;
|
| 2088 |
+
font-size: 0.65rem;
|
| 2089 |
+
}
|
| 2090 |
+
|
| 2091 |
+
.field-info-tip {
|
| 2092 |
+
max-width: min(180px, calc(100vw - 12px));
|
| 2093 |
+
font-size: 0.64rem;
|
| 2094 |
+
padding: 5px 7px;
|
| 2095 |
+
}
|
| 2096 |
}
|
| 2097 |
|
src/ui/ComparePanel.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import { reorderCompareItems, sortCompareItemsByCosine } from './compareCosine.js';
|
| 2 |
import { parseCompareInput } from './parseCompareGroups.js';
|
| 3 |
import { saeControlsMarkup, wireSaeControls } from './SaeControls.js';
|
|
|
|
| 4 |
|
| 5 |
/**
|
| 6 |
* Typical auto-manual lexicon in English (parts, fluids, systems).
|
|
@@ -124,7 +125,7 @@ export class ComparePanel {
|
|
| 124 |
|
| 125 |
<form id="compare-form" class="sidebar-form">
|
| 126 |
<div class="input-group">
|
| 127 |
-
<label for="compare-tokens">Tokens / Words, or GROUP_name = tokens (comma, space, or newline)</label>
|
| 128 |
<textarea id="compare-tokens" rows="6" placeholder="e.g. wheel, engine… or GROUP_1 = car, truck GROUP_2 = grace, hope" required></textarea>
|
| 129 |
</div>
|
| 130 |
|
|
@@ -146,7 +147,7 @@ export class ComparePanel {
|
|
| 146 |
<div id="compare-groups-legend" class="compare-groups-legend" hidden></div>
|
| 147 |
<label id="compare-dim-sort-row" class="compare-dim-sort" hidden title="Reorder X axis by |mean_G1 − mean_G2| so group differences cluster on the left">
|
| 148 |
<input type="checkbox" id="compare-dim-sort-toggle">
|
| 149 |
-
<span>Sort dims by group contrast</span>
|
| 150 |
</label>
|
| 151 |
|
| 152 |
<div class="compare-cosine-header">
|
|
|
|
| 1 |
import { reorderCompareItems, sortCompareItemsByCosine } from './compareCosine.js';
|
| 2 |
import { parseCompareInput } from './parseCompareGroups.js';
|
| 3 |
import { saeControlsMarkup, wireSaeControls } from './SaeControls.js';
|
| 4 |
+
import { FIELD_INFO, infoTipMarkup } from './fieldInfo.js';
|
| 5 |
|
| 6 |
/**
|
| 7 |
* Typical auto-manual lexicon in English (parts, fluids, systems).
|
|
|
|
| 125 |
|
| 126 |
<form id="compare-form" class="sidebar-form">
|
| 127 |
<div class="input-group">
|
| 128 |
+
<label for="compare-tokens"><span class="field-label-text">Tokens / Words, or GROUP_name = tokens (comma, space, or newline)</span>${infoTipMarkup(FIELD_INFO.compareTokens)}</label>
|
| 129 |
<textarea id="compare-tokens" rows="6" placeholder="e.g. wheel, engine… or GROUP_1 = car, truck GROUP_2 = grace, hope" required></textarea>
|
| 130 |
</div>
|
| 131 |
|
|
|
|
| 147 |
<div id="compare-groups-legend" class="compare-groups-legend" hidden></div>
|
| 148 |
<label id="compare-dim-sort-row" class="compare-dim-sort" hidden title="Reorder X axis by |mean_G1 − mean_G2| so group differences cluster on the left">
|
| 149 |
<input type="checkbox" id="compare-dim-sort-toggle">
|
| 150 |
+
<span class="field-label-text">Sort dims by group contrast</span>${infoTipMarkup(FIELD_INFO.dimSort)}
|
| 151 |
</label>
|
| 152 |
|
| 153 |
<div class="compare-cosine-header">
|
src/ui/LandscapeGate.js
CHANGED
|
@@ -1,9 +1,6 @@
|
|
| 1 |
/**
|
| 2 |
-
*
|
| 3 |
-
*
|
| 4 |
-
* Landscape = recommended. Portrait shows a dismissible overlay; after dismiss,
|
| 5 |
-
* sessionStorage prevents re-spam until a new session. Does not lock orientation
|
| 6 |
-
* and does not pause the render loop.
|
| 7 |
*/
|
| 8 |
|
| 9 |
import { MOBILE_MQ, isMobileViewport } from './CollapsibleDock.js';
|
|
@@ -42,16 +39,16 @@ export function dismissLandscapeGate(session, key = LANDSCAPE_DISMISS_KEY) {
|
|
| 42 |
}
|
| 43 |
|
| 44 |
/**
|
| 45 |
-
*
|
| 46 |
-
* @param {{ isPortrait: boolean, dismissed: boolean, isMobile: boolean }}
|
| 47 |
*/
|
| 48 |
-
export function shouldShowLandscapeGate(
|
| 49 |
-
|
| 50 |
-
if (!state.isPortrait) return false;
|
| 51 |
-
if (state.dismissed) return false;
|
| 52 |
-
return true;
|
| 53 |
}
|
| 54 |
|
|
|
|
|
|
|
|
|
|
| 55 |
export class LandscapeGate {
|
| 56 |
/**
|
| 57 |
* @param {object} options
|
|
@@ -66,80 +63,36 @@ export class LandscapeGate {
|
|
| 66 |
throw new Error('LandscapeGate requires options.parent');
|
| 67 |
}
|
| 68 |
this._doc = options.doc || document;
|
| 69 |
-
this._session = options.sessionStorage !== undefined
|
| 70 |
-
? options.sessionStorage
|
| 71 |
-
: (typeof sessionStorage !== 'undefined' ? sessionStorage : null);
|
| 72 |
-
this._isMobile = typeof options.isMobile === 'function'
|
| 73 |
-
? options.isMobile
|
| 74 |
-
: () => isMobileViewport();
|
| 75 |
-
this._isPortrait = typeof options.isPortrait === 'function'
|
| 76 |
-
? options.isPortrait
|
| 77 |
-
: () => isPhonePortrait();
|
| 78 |
-
|
| 79 |
this.overlay = this._doc.createElement('div');
|
| 80 |
this.overlay.id = 'landscape-gate';
|
| 81 |
this.overlay.className = 'landscape-gate hidden';
|
| 82 |
-
this.overlay.setAttribute('
|
| 83 |
-
this.overlay.setAttribute('aria-
|
| 84 |
-
|
| 85 |
-
this.overlay.innerHTML =
|
| 86 |
-
<div class="landscape-gate-card">
|
| 87 |
-
<div class="landscape-gate-icon" aria-hidden="true">↻</div>
|
| 88 |
-
<h2 id="landscape-gate-title">Better in landscape</h2>
|
| 89 |
-
<p class="landscape-gate-copy">Rotate your phone for a better experience. You can stay in portrait if you prefer.</p>
|
| 90 |
-
<button type="button" class="btn-primary landscape-gate-dismiss">Got it</button>
|
| 91 |
-
</div>
|
| 92 |
-
`;
|
| 93 |
-
|
| 94 |
options.parent.appendChild(this.overlay);
|
| 95 |
-
|
| 96 |
-
this._dismissBtn = this.overlay.querySelector('.landscape-gate-dismiss');
|
| 97 |
-
this._onDismiss = () => this.dismiss();
|
| 98 |
-
this._onBackdrop = (e) => {
|
| 99 |
-
if (e.target === this.overlay) this.dismiss();
|
| 100 |
-
};
|
| 101 |
-
this._onResize = () => this.refresh();
|
| 102 |
-
|
| 103 |
-
this._dismissBtn.addEventListener('click', this._onDismiss);
|
| 104 |
-
this.overlay.addEventListener('click', this._onBackdrop);
|
| 105 |
-
if (typeof window !== 'undefined') {
|
| 106 |
-
window.addEventListener('resize', this._onResize);
|
| 107 |
-
window.addEventListener('orientationchange', this._onResize);
|
| 108 |
-
}
|
| 109 |
-
|
| 110 |
-
this.refresh();
|
| 111 |
}
|
| 112 |
|
| 113 |
/** @returns {boolean} */
|
| 114 |
get isVisible() {
|
| 115 |
-
return
|
| 116 |
}
|
| 117 |
|
| 118 |
refresh() {
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
isPortrait: this._isPortrait(),
|
| 122 |
-
dismissed: wasLandscapeGateDismissed(this._session),
|
| 123 |
-
});
|
| 124 |
-
this.overlay.classList.toggle('hidden', !show);
|
| 125 |
}
|
| 126 |
|
| 127 |
dismiss() {
|
| 128 |
-
|
| 129 |
-
this.overlay.classList.add('hidden');
|
| 130 |
}
|
| 131 |
|
| 132 |
destroy() {
|
| 133 |
-
this._dismissBtn.removeEventListener('click', this._onDismiss);
|
| 134 |
-
this.overlay.removeEventListener('click', this._onBackdrop);
|
| 135 |
-
if (typeof window !== 'undefined') {
|
| 136 |
-
window.removeEventListener('resize', this._onResize);
|
| 137 |
-
window.removeEventListener('orientationchange', this._onResize);
|
| 138 |
-
}
|
| 139 |
if (this.overlay.parentNode) {
|
| 140 |
this.overlay.parentNode.removeChild(this.overlay);
|
| 141 |
}
|
| 142 |
}
|
| 143 |
}
|
| 144 |
|
| 145 |
-
export { MOBILE_MQ };
|
|
|
|
| 1 |
/**
|
| 2 |
+
* Landscape gate (retired): portrait overlay was removed — phone portrait is the
|
| 3 |
+
* preferred mobile layout. Helpers remain for tests / TouchControls selectors.
|
|
|
|
|
|
|
|
|
|
| 4 |
*/
|
| 5 |
|
| 6 |
import { MOBILE_MQ, isMobileViewport } from './CollapsibleDock.js';
|
|
|
|
| 39 |
}
|
| 40 |
|
| 41 |
/**
|
| 42 |
+
* Always false — landscape nudge overlay retired (portrait is preferred on phone).
|
| 43 |
+
* @param {{ isPortrait?: boolean, dismissed?: boolean, isMobile?: boolean }} [_state]
|
| 44 |
*/
|
| 45 |
+
export function shouldShowLandscapeGate(_state) {
|
| 46 |
+
return false;
|
|
|
|
|
|
|
|
|
|
| 47 |
}
|
| 48 |
|
| 49 |
+
/**
|
| 50 |
+
* No-op mount kept so callers/tests stay stable; never shows overlay.
|
| 51 |
+
*/
|
| 52 |
export class LandscapeGate {
|
| 53 |
/**
|
| 54 |
* @param {object} options
|
|
|
|
| 63 |
throw new Error('LandscapeGate requires options.parent');
|
| 64 |
}
|
| 65 |
this._doc = options.doc || document;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
this.overlay = this._doc.createElement('div');
|
| 67 |
this.overlay.id = 'landscape-gate';
|
| 68 |
this.overlay.className = 'landscape-gate hidden';
|
| 69 |
+
this.overlay.setAttribute('hidden', '');
|
| 70 |
+
this.overlay.setAttribute('aria-hidden', 'true');
|
| 71 |
+
// Intentionally empty — no cartel/copy.
|
| 72 |
+
this.overlay.innerHTML = '';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
options.parent.appendChild(this.overlay);
|
| 74 |
+
this._dismissBtn = null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
}
|
| 76 |
|
| 77 |
/** @returns {boolean} */
|
| 78 |
get isVisible() {
|
| 79 |
+
return false;
|
| 80 |
}
|
| 81 |
|
| 82 |
refresh() {
|
| 83 |
+
this.overlay.classList.add('hidden');
|
| 84 |
+
this.overlay.setAttribute('hidden', '');
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
}
|
| 86 |
|
| 87 |
dismiss() {
|
| 88 |
+
this.refresh();
|
|
|
|
| 89 |
}
|
| 90 |
|
| 91 |
destroy() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
if (this.overlay.parentNode) {
|
| 93 |
this.overlay.parentNode.removeChild(this.overlay);
|
| 94 |
}
|
| 95 |
}
|
| 96 |
}
|
| 97 |
|
| 98 |
+
export { MOBILE_MQ, isMobileViewport };
|
src/ui/SaeControls.js
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
| 7 |
saveSaeSettings,
|
| 8 |
resolveSaeSettings,
|
| 9 |
} from './saeControlsDefaults.js';
|
|
|
|
| 10 |
|
| 11 |
/**
|
| 12 |
* @param {string} idPrefix unique prefix so Arithmetic/Compare don't clash on ids
|
|
@@ -20,9 +21,12 @@ export function saeControlsMarkup(idPrefix) {
|
|
| 20 |
<button type="submit" id="${p}-btn-primary" class="btn-primary sae-cta-main">
|
| 21 |
PRIMARY
|
| 22 |
</button>
|
| 23 |
-
<
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
| 26 |
</div>
|
| 27 |
|
| 28 |
<div class="sae-train-row">
|
|
@@ -31,11 +35,11 @@ export function saeControlsMarkup(idPrefix) {
|
|
| 31 |
</div>
|
| 32 |
|
| 33 |
<div id="${p}-sae-params" class="sae-params-panel hidden" hidden>
|
| 34 |
-
<label>hidden_dim (cap) <input type="number" id="${p}-sae-hidden" min="32" max="32768" step="1" /></label>
|
| 35 |
-
<label>k (cap) <input type="number" id="${p}-sae-k" min="1" max="4096" step="1" /></label>
|
| 36 |
-
<label>epochs <input type="number" id="${p}-sae-epochs" min="1" max="500" step="1" /></label>
|
| 37 |
-
<label>lr <input type="number" id="${p}-sae-lr" min="0.00001" max="1" step="0.0001" /></label>
|
| 38 |
-
<label>batch_size <input type="number" id="${p}-sae-batch" min="1" max="2048" step="1" /></label>
|
| 39 |
<p class="sae-params-hint">Trains on current Visualize scope. Dims/epochs auto-scale for speed (MPS/CUDA when available).</p>
|
| 40 |
</div>
|
| 41 |
|
|
|
|
| 7 |
saveSaeSettings,
|
| 8 |
resolveSaeSettings,
|
| 9 |
} from './saeControlsDefaults.js';
|
| 10 |
+
import { FIELD_INFO, infoTipMarkup } from './fieldInfo.js';
|
| 11 |
|
| 12 |
/**
|
| 13 |
* @param {string} idPrefix unique prefix so Arithmetic/Compare don't clash on ids
|
|
|
|
| 21 |
<button type="submit" id="${p}-btn-primary" class="btn-primary sae-cta-main">
|
| 22 |
PRIMARY
|
| 23 |
</button>
|
| 24 |
+
<div class="sae-toggle-with-info">
|
| 25 |
+
<button type="button" id="${p}-btn-sae-toggle" class="btn-sae-toggle" aria-pressed="false">
|
| 26 |
+
Clean/Denoise (SAE)
|
| 27 |
+
</button>
|
| 28 |
+
${infoTipMarkup(FIELD_INFO.saeToggle)}
|
| 29 |
+
</div>
|
| 30 |
</div>
|
| 31 |
|
| 32 |
<div class="sae-train-row">
|
|
|
|
| 35 |
</div>
|
| 36 |
|
| 37 |
<div id="${p}-sae-params" class="sae-params-panel hidden" hidden>
|
| 38 |
+
<label><span class="field-label-text">hidden_dim (cap) ${infoTipMarkup(FIELD_INFO.saeHidden)}</span><input type="number" id="${p}-sae-hidden" min="32" max="32768" step="1" /></label>
|
| 39 |
+
<label><span class="field-label-text">k (cap) ${infoTipMarkup(FIELD_INFO.saeK)}</span><input type="number" id="${p}-sae-k" min="1" max="4096" step="1" /></label>
|
| 40 |
+
<label><span class="field-label-text">epochs ${infoTipMarkup(FIELD_INFO.saeEpochs)}</span><input type="number" id="${p}-sae-epochs" min="1" max="500" step="1" /></label>
|
| 41 |
+
<label><span class="field-label-text">lr ${infoTipMarkup(FIELD_INFO.saeLr)}</span><input type="number" id="${p}-sae-lr" min="0.00001" max="1" step="0.0001" /></label>
|
| 42 |
+
<label><span class="field-label-text">batch_size ${infoTipMarkup(FIELD_INFO.saeBatch)}</span><input type="number" id="${p}-sae-batch" min="1" max="2048" step="1" /></label>
|
| 43 |
<p class="sae-params-hint">Trains on current Visualize scope. Dims/epochs auto-scale for speed (MPS/CUDA when available).</p>
|
| 44 |
</div>
|
| 45 |
|
src/ui/Sidebar.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
|
|
|
|
|
| 1 |
/**
|
| 2 |
* Left Sidebar Control Panel component for A - B + C Vector Arithmetic operations.
|
| 3 |
*/
|
|
@@ -21,17 +23,17 @@ export class Sidebar {
|
|
| 21 |
|
| 22 |
<form id="arithmetic-form" class="sidebar-form">
|
| 23 |
<div class="input-group positive">
|
| 24 |
-
<label for="word-a"><span class="op">+</span> Base Word A</label>
|
| 25 |
<input type="text" id="word-a" value="king" required autocomplete="off" />
|
| 26 |
</div>
|
| 27 |
|
| 28 |
<div class="input-group negative">
|
| 29 |
-
<label for="word-b"><span class="op">-</span> Subtract Word B</label>
|
| 30 |
<input type="text" id="word-b" value="man" required autocomplete="off" />
|
| 31 |
</div>
|
| 32 |
|
| 33 |
<div class="input-group positive">
|
| 34 |
-
<label for="word-c"><span class="op">+</span> Add Word C</label>
|
| 35 |
<input type="text" id="word-c" value="woman" required autocomplete="off" />
|
| 36 |
</div>
|
| 37 |
|
|
|
|
| 1 |
+
import { FIELD_INFO, infoTipMarkup } from './fieldInfo.js';
|
| 2 |
+
|
| 3 |
/**
|
| 4 |
* Left Sidebar Control Panel component for A - B + C Vector Arithmetic operations.
|
| 5 |
*/
|
|
|
|
| 23 |
|
| 24 |
<form id="arithmetic-form" class="sidebar-form">
|
| 25 |
<div class="input-group positive">
|
| 26 |
+
<label for="word-a"><span class="field-label-text"><span class="op">+</span> Base Word A</span>${infoTipMarkup(FIELD_INFO.wordA)}</label>
|
| 27 |
<input type="text" id="word-a" value="king" required autocomplete="off" />
|
| 28 |
</div>
|
| 29 |
|
| 30 |
<div class="input-group negative">
|
| 31 |
+
<label for="word-b"><span class="field-label-text"><span class="op">-</span> Subtract Word B</span>${infoTipMarkup(FIELD_INFO.wordB)}</label>
|
| 32 |
<input type="text" id="word-b" value="man" required autocomplete="off" />
|
| 33 |
</div>
|
| 34 |
|
| 35 |
<div class="input-group positive">
|
| 36 |
+
<label for="word-c"><span class="field-label-text"><span class="op">+</span> Add Word C</span>${infoTipMarkup(FIELD_INFO.wordC)}</label>
|
| 37 |
<input type="text" id="word-c" value="woman" required autocomplete="off" />
|
| 38 |
</div>
|
| 39 |
|
src/ui/ThreadSliders.js
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
| 4 |
resolveSpatialDefaults,
|
| 5 |
SPATIAL_SLIDER_BINDINGS,
|
| 6 |
} from './spatialSliderDefaults.js';
|
|
|
|
| 7 |
|
| 8 |
/**
|
| 9 |
* Returns HTML markup for the spatial sliders control panel.
|
|
@@ -25,7 +26,7 @@ export function threadSlidersMarkup(config = {}) {
|
|
| 25 |
<!-- Slider 1: Spacing X -->
|
| 26 |
<div class="slider-group">
|
| 27 |
<div class="slider-header">
|
| 28 |
-
<label for="thread-spacing-slider">Spacing (X):</label>
|
| 29 |
<span id="thread-spacing-val" class="slider-val">${spacing.toFixed(2)}</span>
|
| 30 |
</div>
|
| 31 |
<input type="range" id="thread-spacing-slider" min="0.4" max="2.0" step="0.05" value="${spacing}" title="Double-click: restore default">
|
|
@@ -34,7 +35,7 @@ export function threadSlidersMarkup(config = {}) {
|
|
| 34 |
<!-- Slider 2: Vector Distance Y -->
|
| 35 |
<div class="slider-group">
|
| 36 |
<div class="slider-header">
|
| 37 |
-
<label for="thread-vector-dist-slider">Vector Distance (Y):</label>
|
| 38 |
<span id="thread-vector-dist-val" class="slider-val">${vectorDist.toFixed(1)}</span>
|
| 39 |
</div>
|
| 40 |
<input type="range" id="thread-vector-dist-slider" min="1.0" max="19.0" step="0.1" value="${vectorDist}" title="Double-click: restore default">
|
|
@@ -43,7 +44,7 @@ export function threadSlidersMarkup(config = {}) {
|
|
| 43 |
<!-- Slider 3: Amplitude Y -->
|
| 44 |
<div class="slider-group">
|
| 45 |
<div class="slider-header">
|
| 46 |
-
<label for="thread-amplitude-y-slider">Amplitude (Y):</label>
|
| 47 |
<span id="thread-amplitude-y-val" class="slider-val">${amplitudeY.toFixed(1)}</span>
|
| 48 |
</div>
|
| 49 |
<input type="range" id="thread-amplitude-y-slider" min="1.0" max="40.0" step="0.1" value="${amplitudeY}" title="Double-click: restore default">
|
|
@@ -52,7 +53,7 @@ export function threadSlidersMarkup(config = {}) {
|
|
| 52 |
<!-- Slider 4: Width Z -->
|
| 53 |
<div class="slider-group">
|
| 54 |
<div class="slider-header">
|
| 55 |
-
<label for="thread-width-slider">Length (Z):</label>
|
| 56 |
<span id="thread-width-val" class="slider-val">${width.toFixed(3)}</span>
|
| 57 |
</div>
|
| 58 |
<input type="range" id="thread-width-slider" min="0.001" max="0.2" step="0.001" value="${width}" title="Double-click: restore default">
|
|
@@ -61,7 +62,7 @@ export function threadSlidersMarkup(config = {}) {
|
|
| 61 |
<!-- Slider 5: Thickness -->
|
| 62 |
<div class="slider-group">
|
| 63 |
<div class="slider-header">
|
| 64 |
-
<label for="thread-thickness-slider">Point Thickness:</label>
|
| 65 |
<span id="thread-thickness-val" class="slider-val">${thickness.toFixed(2)}</span>
|
| 66 |
</div>
|
| 67 |
<input type="range" id="thread-thickness-slider" min="0.01" max="0.09" step="0.01" value="${thickness}" title="Double-click: restore default">
|
|
|
|
| 4 |
resolveSpatialDefaults,
|
| 5 |
SPATIAL_SLIDER_BINDINGS,
|
| 6 |
} from './spatialSliderDefaults.js';
|
| 7 |
+
import { FIELD_INFO, infoTipMarkup } from './fieldInfo.js';
|
| 8 |
|
| 9 |
/**
|
| 10 |
* Returns HTML markup for the spatial sliders control panel.
|
|
|
|
| 26 |
<!-- Slider 1: Spacing X -->
|
| 27 |
<div class="slider-group">
|
| 28 |
<div class="slider-header">
|
| 29 |
+
<label for="thread-spacing-slider"><span class="field-label-text">Spacing (X):</span>${infoTipMarkup(FIELD_INFO.spacingX)}</label>
|
| 30 |
<span id="thread-spacing-val" class="slider-val">${spacing.toFixed(2)}</span>
|
| 31 |
</div>
|
| 32 |
<input type="range" id="thread-spacing-slider" min="0.4" max="2.0" step="0.05" value="${spacing}" title="Double-click: restore default">
|
|
|
|
| 35 |
<!-- Slider 2: Vector Distance Y -->
|
| 36 |
<div class="slider-group">
|
| 37 |
<div class="slider-header">
|
| 38 |
+
<label for="thread-vector-dist-slider"><span class="field-label-text">Vector Distance (Y):</span>${infoTipMarkup(FIELD_INFO.vectorDistY)}</label>
|
| 39 |
<span id="thread-vector-dist-val" class="slider-val">${vectorDist.toFixed(1)}</span>
|
| 40 |
</div>
|
| 41 |
<input type="range" id="thread-vector-dist-slider" min="1.0" max="19.0" step="0.1" value="${vectorDist}" title="Double-click: restore default">
|
|
|
|
| 44 |
<!-- Slider 3: Amplitude Y -->
|
| 45 |
<div class="slider-group">
|
| 46 |
<div class="slider-header">
|
| 47 |
+
<label for="thread-amplitude-y-slider"><span class="field-label-text">Amplitude (Y):</span>${infoTipMarkup(FIELD_INFO.amplitudeY)}</label>
|
| 48 |
<span id="thread-amplitude-y-val" class="slider-val">${amplitudeY.toFixed(1)}</span>
|
| 49 |
</div>
|
| 50 |
<input type="range" id="thread-amplitude-y-slider" min="1.0" max="40.0" step="0.1" value="${amplitudeY}" title="Double-click: restore default">
|
|
|
|
| 53 |
<!-- Slider 4: Width Z -->
|
| 54 |
<div class="slider-group">
|
| 55 |
<div class="slider-header">
|
| 56 |
+
<label for="thread-width-slider"><span class="field-label-text">Length (Z):</span>${infoTipMarkup(FIELD_INFO.lengthZ)}</label>
|
| 57 |
<span id="thread-width-val" class="slider-val">${width.toFixed(3)}</span>
|
| 58 |
</div>
|
| 59 |
<input type="range" id="thread-width-slider" min="0.001" max="0.2" step="0.001" value="${width}" title="Double-click: restore default">
|
|
|
|
| 62 |
<!-- Slider 5: Thickness -->
|
| 63 |
<div class="slider-group">
|
| 64 |
<div class="slider-header">
|
| 65 |
+
<label for="thread-thickness-slider"><span class="field-label-text">Point Thickness:</span>${infoTipMarkup(FIELD_INFO.thickness)}</label>
|
| 66 |
<span id="thread-thickness-val" class="slider-val">${thickness.toFixed(2)}</span>
|
| 67 |
</div>
|
| 68 |
<input type="range" id="thread-thickness-slider" min="0.01" max="0.09" step="0.01" value="${thickness}" title="Double-click: restore default">
|
src/ui/TouchControls.js
CHANGED
|
@@ -16,6 +16,8 @@ const UI_BLOCK_SELECTOR = [
|
|
| 16 |
'#touch-controls',
|
| 17 |
'.glass-modal',
|
| 18 |
'.modal-overlay',
|
|
|
|
|
|
|
| 19 |
].join(',');
|
| 20 |
|
| 21 |
/**
|
|
|
|
| 16 |
'#touch-controls',
|
| 17 |
'.glass-modal',
|
| 18 |
'.modal-overlay',
|
| 19 |
+
'.field-info-tip',
|
| 20 |
+
'.field-info-btn',
|
| 21 |
].join(',');
|
| 22 |
|
| 23 |
/**
|
src/ui/VisualizationControls.js
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
| 21 |
writeCollapsedPreference,
|
| 22 |
isMobileViewport,
|
| 23 |
} from './CollapsibleDock.js';
|
|
|
|
| 24 |
|
| 25 |
export const VIZ_PANEL_COLLAPSE_KEY = `${VIZ_STORAGE_PREFIX}panelCollapsed`;
|
| 26 |
|
|
@@ -113,7 +114,7 @@ export function visualizationControlsMarkup(config = DEFAULT_VISUALIZATION_SETTI
|
|
| 113 |
<h3 class="sliders-title">Visualization</h3>
|
| 114 |
|
| 115 |
<div class="viz-filter-group">
|
| 116 |
-
<span class="viz-filter-label">Show:</span>
|
| 117 |
<div class="viz-segmented" role="radiogroup" aria-label="Sign filter">
|
| 118 |
<label class="viz-seg-option">
|
| 119 |
<input type="radio" name="viz-filter-mode" value="all" ${checked('all')}>
|
|
@@ -133,17 +134,17 @@ export function visualizationControlsMarkup(config = DEFAULT_VISUALIZATION_SETTI
|
|
| 133 |
<div class="viz-colors-section">
|
| 134 |
<div class="viz-colors-title">Colors</div>
|
| 135 |
<div class="viz-color-row">
|
| 136 |
-
<label for="viz-color-positive-hex">+1</label>
|
| 137 |
<input type="color" id="viz-color-positive-swatch" class="viz-color-swatch" value="${s.colorPositive}" title="Positive (+1)" aria-label="Positive color swatch">
|
| 138 |
<input type="text" id="viz-color-positive-hex" class="viz-color-hex" value="${s.colorPositive}" maxlength="7" spellcheck="false" placeholder="#FFE600" title="Positive (+1) hex">
|
| 139 |
</div>
|
| 140 |
<div class="viz-color-row">
|
| 141 |
-
<label for="viz-color-zero-hex">0</label>
|
| 142 |
<input type="color" id="viz-color-zero-swatch" class="viz-color-swatch" value="${s.colorZero}" title="Zero (0)" aria-label="Zero color swatch">
|
| 143 |
<input type="text" id="viz-color-zero-hex" class="viz-color-hex" value="${s.colorZero}" maxlength="7" spellcheck="false" placeholder="#000000" title="Zero (0) hex">
|
| 144 |
</div>
|
| 145 |
<div class="viz-color-row">
|
| 146 |
-
<label for="viz-color-negative-hex">−1</label>
|
| 147 |
<input type="color" id="viz-color-negative-swatch" class="viz-color-swatch" value="${s.colorNegative}" title="Negative (−1)" aria-label="Negative color swatch">
|
| 148 |
<input type="text" id="viz-color-negative-hex" class="viz-color-hex" value="${s.colorNegative}" maxlength="7" spellcheck="false" placeholder="#9900E6" title="Negative (−1) hex">
|
| 149 |
</div>
|
|
@@ -151,15 +152,18 @@ export function visualizationControlsMarkup(config = DEFAULT_VISUALIZATION_SETTI
|
|
| 151 |
|
| 152 |
<div class="viz-coverage-row slider-group">
|
| 153 |
<div class="slider-header">
|
| 154 |
-
<label for="viz-zero-coverage-slider">Zero coverage:</label>
|
| 155 |
<span id="viz-zero-coverage-val" class="slider-val">${s.zeroCoverage}%</span>
|
| 156 |
</div>
|
| 157 |
<input type="range" id="viz-zero-coverage-slider" min="${ZERO_COVERAGE_MIN}" max="${ZERO_COVERAGE_MAX}" step="1" value="${s.zeroCoverage}" title="How much of ± range stays at the zero color before blending to +1/−1">
|
| 158 |
</div>
|
| 159 |
|
| 160 |
-
<
|
| 161 |
-
${
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
| 163 |
|
| 164 |
<button type="button" id="viz-reset-btn" class="viz-reset-btn">Reset</button>
|
| 165 |
</div>
|
|
|
|
| 21 |
writeCollapsedPreference,
|
| 22 |
isMobileViewport,
|
| 23 |
} from './CollapsibleDock.js';
|
| 24 |
+
import { FIELD_INFO, infoTipMarkup } from './fieldInfo.js';
|
| 25 |
|
| 26 |
export const VIZ_PANEL_COLLAPSE_KEY = `${VIZ_STORAGE_PREFIX}panelCollapsed`;
|
| 27 |
|
|
|
|
| 114 |
<h3 class="sliders-title">Visualization</h3>
|
| 115 |
|
| 116 |
<div class="viz-filter-group">
|
| 117 |
+
<span class="viz-filter-label"><span class="field-label-text">Show:</span>${infoTipMarkup(FIELD_INFO.vizFilter)}</span>
|
| 118 |
<div class="viz-segmented" role="radiogroup" aria-label="Sign filter">
|
| 119 |
<label class="viz-seg-option">
|
| 120 |
<input type="radio" name="viz-filter-mode" value="all" ${checked('all')}>
|
|
|
|
| 134 |
<div class="viz-colors-section">
|
| 135 |
<div class="viz-colors-title">Colors</div>
|
| 136 |
<div class="viz-color-row">
|
| 137 |
+
<label for="viz-color-positive-hex"><span class="field-label-text">+1</span>${infoTipMarkup(FIELD_INFO.colorPos)}</label>
|
| 138 |
<input type="color" id="viz-color-positive-swatch" class="viz-color-swatch" value="${s.colorPositive}" title="Positive (+1)" aria-label="Positive color swatch">
|
| 139 |
<input type="text" id="viz-color-positive-hex" class="viz-color-hex" value="${s.colorPositive}" maxlength="7" spellcheck="false" placeholder="#FFE600" title="Positive (+1) hex">
|
| 140 |
</div>
|
| 141 |
<div class="viz-color-row">
|
| 142 |
+
<label for="viz-color-zero-hex"><span class="field-label-text">0</span>${infoTipMarkup(FIELD_INFO.colorZero)}</label>
|
| 143 |
<input type="color" id="viz-color-zero-swatch" class="viz-color-swatch" value="${s.colorZero}" title="Zero (0)" aria-label="Zero color swatch">
|
| 144 |
<input type="text" id="viz-color-zero-hex" class="viz-color-hex" value="${s.colorZero}" maxlength="7" spellcheck="false" placeholder="#000000" title="Zero (0) hex">
|
| 145 |
</div>
|
| 146 |
<div class="viz-color-row">
|
| 147 |
+
<label for="viz-color-negative-hex"><span class="field-label-text">−1</span>${infoTipMarkup(FIELD_INFO.colorNeg)}</label>
|
| 148 |
<input type="color" id="viz-color-negative-swatch" class="viz-color-swatch" value="${s.colorNegative}" title="Negative (−1)" aria-label="Negative color swatch">
|
| 149 |
<input type="text" id="viz-color-negative-hex" class="viz-color-hex" value="${s.colorNegative}" maxlength="7" spellcheck="false" placeholder="#9900E6" title="Negative (−1) hex">
|
| 150 |
</div>
|
|
|
|
| 152 |
|
| 153 |
<div class="viz-coverage-row slider-group">
|
| 154 |
<div class="slider-header">
|
| 155 |
+
<label for="viz-zero-coverage-slider"><span class="field-label-text">Zero coverage:</span>${infoTipMarkup(FIELD_INFO.zeroCoverage)}</label>
|
| 156 |
<span id="viz-zero-coverage-val" class="slider-val">${s.zeroCoverage}%</span>
|
| 157 |
</div>
|
| 158 |
<input type="range" id="viz-zero-coverage-slider" min="${ZERO_COVERAGE_MIN}" max="${ZERO_COVERAGE_MAX}" step="1" value="${s.zeroCoverage}" title="How much of ± range stays at the zero color before blending to +1/−1">
|
| 159 |
</div>
|
| 160 |
|
| 161 |
+
<div class="viz-labels-row">
|
| 162 |
+
<button type="button" id="viz-labels-toggle" class="viz-labels-toggle" aria-pressed="${s.labelsVisible ? 'true' : 'false'}" title="Show or hide floating thread labels">
|
| 163 |
+
${labelsToggleButtonText(s.labelsVisible)}
|
| 164 |
+
</button>
|
| 165 |
+
${infoTipMarkup(FIELD_INFO.labelsToggle)}
|
| 166 |
+
</div>
|
| 167 |
|
| 168 |
<button type="button" id="viz-reset-btn" class="viz-reset-btn">Reset</button>
|
| 169 |
</div>
|
src/ui/fieldInfo.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Compact field-info tips ("i") for editable controls.
|
| 3 |
+
* Tap/click opens a short English tip (mobile-friendly; not hover-only title).
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
/** Soft cap so tips stay readable on phone panels. */
|
| 7 |
+
export const MAX_FIELD_INFO_LEN = 28;
|
| 8 |
+
|
| 9 |
+
/**
|
| 10 |
+
* Short EN help for every user-editable value/field.
|
| 11 |
+
* Keep copy ≤ MAX_FIELD_INFO_LEN.
|
| 12 |
+
*/
|
| 13 |
+
export const FIELD_INFO = Object.freeze({
|
| 14 |
+
wordA: 'Base term (+).',
|
| 15 |
+
wordB: 'Subtract this.',
|
| 16 |
+
wordC: 'Add this.',
|
| 17 |
+
compareTokens: 'Words or GROUP_x = list.',
|
| 18 |
+
dimSort: 'Order X by group Δ.',
|
| 19 |
+
saeToggle: 'Paint SAE activations.',
|
| 20 |
+
saeHidden: 'Max latent width.',
|
| 21 |
+
saeK: 'Keep top-K features.',
|
| 22 |
+
saeEpochs: 'Train passes.',
|
| 23 |
+
saeLr: 'Step size.',
|
| 24 |
+
saeBatch: 'Batch size.',
|
| 25 |
+
spacingX: 'Gap between threads.',
|
| 26 |
+
vectorDistY: 'Stack spacing.',
|
| 27 |
+
amplitudeY: 'Peak height.',
|
| 28 |
+
lengthZ: 'Thread depth.',
|
| 29 |
+
thickness: 'Point size.',
|
| 30 |
+
vizFilter: 'Which signs show.',
|
| 31 |
+
colorPos: 'Color at +1.',
|
| 32 |
+
colorZero: 'Color at 0.',
|
| 33 |
+
colorNeg: 'Color at −1.',
|
| 34 |
+
zeroCoverage: '% held at zero.',
|
| 35 |
+
labelsToggle: 'Floating names.',
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
/**
|
| 39 |
+
* @param {unknown} value
|
| 40 |
+
* @returns {string}
|
| 41 |
+
*/
|
| 42 |
+
export function escapeHtmlAttr(value) {
|
| 43 |
+
return String(value ?? '')
|
| 44 |
+
.replace(/&/g, '&')
|
| 45 |
+
.replace(/"/g, '"')
|
| 46 |
+
.replace(/</g, '<')
|
| 47 |
+
.replace(/>/g, '>');
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/**
|
| 51 |
+
* Inline "i" button markup for a label row.
|
| 52 |
+
* @param {string} text
|
| 53 |
+
* @returns {string}
|
| 54 |
+
*/
|
| 55 |
+
export function infoTipMarkup(text) {
|
| 56 |
+
const t = String(text ?? '').trim();
|
| 57 |
+
if (!t) return '';
|
| 58 |
+
return (
|
| 59 |
+
`<button type="button" class="field-info-btn" aria-label="Info" ` +
|
| 60 |
+
`aria-expanded="false" data-field-info="${escapeHtmlAttr(t)}">i</button>`
|
| 61 |
+
);
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
/**
|
| 65 |
+
* Pure click resolver for one-tip-at-a-time UX.
|
| 66 |
+
* @param {unknown} openBtn currently open trigger (or null)
|
| 67 |
+
* @param {unknown} clickedBtn info button clicked, or null for outside
|
| 68 |
+
* @returns {{ action: 'open'|'close'|'noop', button?: unknown }}
|
| 69 |
+
*/
|
| 70 |
+
export function resolveFieldInfoClick(openBtn, clickedBtn) {
|
| 71 |
+
if (clickedBtn) {
|
| 72 |
+
if (openBtn === clickedBtn) return { action: 'close' };
|
| 73 |
+
return { action: 'open', button: clickedBtn };
|
| 74 |
+
}
|
| 75 |
+
if (openBtn) return { action: 'close' };
|
| 76 |
+
return { action: 'noop' };
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
/**
|
| 80 |
+
* Position tip near the trigger; clamp to viewport (phone-safe).
|
| 81 |
+
* @param {HTMLElement} btn
|
| 82 |
+
* @param {HTMLElement} tip
|
| 83 |
+
*/
|
| 84 |
+
function positionTip(btn, tip) {
|
| 85 |
+
const pad = 8;
|
| 86 |
+
const r = btn.getBoundingClientRect();
|
| 87 |
+
tip.style.visibility = 'hidden';
|
| 88 |
+
tip.style.left = '0px';
|
| 89 |
+
tip.style.top = '0px';
|
| 90 |
+
const tw = tip.offsetWidth || 160;
|
| 91 |
+
const th = tip.offsetHeight || 40;
|
| 92 |
+
let left = r.left + r.width / 2 - tw / 2;
|
| 93 |
+
let top = r.bottom + 6;
|
| 94 |
+
const vw = typeof window !== 'undefined' ? window.innerWidth : 360;
|
| 95 |
+
const vh = typeof window !== 'undefined' ? window.innerHeight : 640;
|
| 96 |
+
left = Math.max(pad, Math.min(left, vw - tw - pad));
|
| 97 |
+
if (top + th > vh - pad) {
|
| 98 |
+
top = r.top - th - 6;
|
| 99 |
+
}
|
| 100 |
+
tip.style.left = `${left}px`;
|
| 101 |
+
tip.style.top = `${Math.max(pad, top)}px`;
|
| 102 |
+
tip.style.visibility = 'visible';
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
/**
|
| 106 |
+
* Event-delegated tips for all `.field-info-btn` under root.
|
| 107 |
+
* One tip open at a time; outside click / Escape closes.
|
| 108 |
+
*
|
| 109 |
+
* @param {ParentNode} [root=document]
|
| 110 |
+
* @returns {{ close: () => void, getOpenButton: () => HTMLElement|null }}
|
| 111 |
+
*/
|
| 112 |
+
export function wireFieldInfo(root = document) {
|
| 113 |
+
/** @type {HTMLElement|null} */
|
| 114 |
+
let tipEl = null;
|
| 115 |
+
/** @type {HTMLElement|null} */
|
| 116 |
+
let openBtn = null;
|
| 117 |
+
|
| 118 |
+
const close = () => {
|
| 119 |
+
if (tipEl && tipEl.parentNode) tipEl.parentNode.removeChild(tipEl);
|
| 120 |
+
tipEl = null;
|
| 121 |
+
if (openBtn) openBtn.setAttribute('aria-expanded', 'false');
|
| 122 |
+
openBtn = null;
|
| 123 |
+
};
|
| 124 |
+
|
| 125 |
+
/**
|
| 126 |
+
* @param {HTMLElement} btn
|
| 127 |
+
*/
|
| 128 |
+
const open = (btn) => {
|
| 129 |
+
close();
|
| 130 |
+
const text = btn.getAttribute('data-field-info') || '';
|
| 131 |
+
if (!text) return;
|
| 132 |
+
tipEl = document.createElement('div');
|
| 133 |
+
tipEl.className = 'field-info-tip';
|
| 134 |
+
tipEl.setAttribute('role', 'tooltip');
|
| 135 |
+
tipEl.textContent = text;
|
| 136 |
+
document.body.appendChild(tipEl);
|
| 137 |
+
openBtn = btn;
|
| 138 |
+
btn.setAttribute('aria-expanded', 'true');
|
| 139 |
+
positionTip(btn, tipEl);
|
| 140 |
+
};
|
| 141 |
+
|
| 142 |
+
/**
|
| 143 |
+
* @param {Event} e
|
| 144 |
+
*/
|
| 145 |
+
const onClick = (e) => {
|
| 146 |
+
const target = e.target;
|
| 147 |
+
if (!target || typeof target.closest !== 'function') return;
|
| 148 |
+
if (target.closest('.field-info-tip')) return;
|
| 149 |
+
|
| 150 |
+
const btn = target.closest('.field-info-btn');
|
| 151 |
+
const inRoot = !!(btn && (root === document || (root.contains && root.contains(btn))));
|
| 152 |
+
const decision = resolveFieldInfoClick(openBtn, inRoot ? btn : null);
|
| 153 |
+
|
| 154 |
+
if (decision.action === 'open') {
|
| 155 |
+
e.preventDefault();
|
| 156 |
+
e.stopPropagation();
|
| 157 |
+
open(/** @type {HTMLElement} */ (decision.button));
|
| 158 |
+
return;
|
| 159 |
+
}
|
| 160 |
+
if (decision.action === 'close') {
|
| 161 |
+
if (inRoot) {
|
| 162 |
+
e.preventDefault();
|
| 163 |
+
e.stopPropagation();
|
| 164 |
+
}
|
| 165 |
+
close();
|
| 166 |
+
}
|
| 167 |
+
};
|
| 168 |
+
|
| 169 |
+
/**
|
| 170 |
+
* @param {KeyboardEvent} e
|
| 171 |
+
*/
|
| 172 |
+
const onKeydown = (e) => {
|
| 173 |
+
if (e.key === 'Escape') close();
|
| 174 |
+
};
|
| 175 |
+
|
| 176 |
+
root.addEventListener('click', onClick);
|
| 177 |
+
if (typeof window !== 'undefined') {
|
| 178 |
+
window.addEventListener('keydown', onKeydown);
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
return {
|
| 182 |
+
close,
|
| 183 |
+
getOpenButton: () => openBtn,
|
| 184 |
+
/** @internal test/teardown */
|
| 185 |
+
destroy() {
|
| 186 |
+
close();
|
| 187 |
+
root.removeEventListener('click', onClick);
|
| 188 |
+
if (typeof window !== 'undefined') {
|
| 189 |
+
window.removeEventListener('keydown', onKeydown);
|
| 190 |
+
}
|
| 191 |
+
},
|
| 192 |
+
};
|
| 193 |
+
}
|
tests/LandscapeGate.test.js
CHANGED
|
@@ -116,8 +116,8 @@ describe('isPhonePortrait', () => {
|
|
| 116 |
});
|
| 117 |
|
| 118 |
describe('shouldShowLandscapeGate', () => {
|
| 119 |
-
it('shows
|
| 120 |
-
expect(shouldShowLandscapeGate({ isMobile: true, isPortrait: true, dismissed: false })).toBe(
|
| 121 |
expect(shouldShowLandscapeGate({ isMobile: true, isPortrait: true, dismissed: true })).toBe(false);
|
| 122 |
expect(shouldShowLandscapeGate({ isMobile: true, isPortrait: false, dismissed: false })).toBe(false);
|
| 123 |
expect(shouldShowLandscapeGate({ isMobile: false, isPortrait: true, dismissed: false })).toBe(false);
|
|
@@ -143,7 +143,7 @@ describe('LandscapeGate', () => {
|
|
| 143 |
session = memorySession();
|
| 144 |
});
|
| 145 |
|
| 146 |
-
it('shows overlay in phone portrait
|
| 147 |
const gate = new LandscapeGate({
|
| 148 |
parent,
|
| 149 |
doc: mockDoc,
|
|
@@ -152,13 +152,10 @@ describe('LandscapeGate', () => {
|
|
| 152 |
isPortrait: () => true,
|
| 153 |
});
|
| 154 |
|
| 155 |
-
expect(gate.isVisible).toBe(true);
|
| 156 |
-
gate._dismissBtn.click();
|
| 157 |
expect(gate.isVisible).toBe(false);
|
| 158 |
-
expect(wasLandscapeGateDismissed(session)).toBe(true);
|
| 159 |
-
|
| 160 |
gate.refresh();
|
| 161 |
expect(gate.isVisible).toBe(false);
|
|
|
|
| 162 |
});
|
| 163 |
|
| 164 |
it('stays hidden on tablet/desktop and landscape phone', () => {
|
|
|
|
| 116 |
});
|
| 117 |
|
| 118 |
describe('shouldShowLandscapeGate', () => {
|
| 119 |
+
it('never shows — landscape cartel retired (portrait preferred)', () => {
|
| 120 |
+
expect(shouldShowLandscapeGate({ isMobile: true, isPortrait: true, dismissed: false })).toBe(false);
|
| 121 |
expect(shouldShowLandscapeGate({ isMobile: true, isPortrait: true, dismissed: true })).toBe(false);
|
| 122 |
expect(shouldShowLandscapeGate({ isMobile: true, isPortrait: false, dismissed: false })).toBe(false);
|
| 123 |
expect(shouldShowLandscapeGate({ isMobile: false, isPortrait: true, dismissed: false })).toBe(false);
|
|
|
|
| 143 |
session = memorySession();
|
| 144 |
});
|
| 145 |
|
| 146 |
+
it('never shows overlay even in phone portrait', () => {
|
| 147 |
const gate = new LandscapeGate({
|
| 148 |
parent,
|
| 149 |
doc: mockDoc,
|
|
|
|
| 152 |
isPortrait: () => true,
|
| 153 |
});
|
| 154 |
|
|
|
|
|
|
|
| 155 |
expect(gate.isVisible).toBe(false);
|
|
|
|
|
|
|
| 156 |
gate.refresh();
|
| 157 |
expect(gate.isVisible).toBe(false);
|
| 158 |
+
expect(gate.overlay.classList.contains('hidden')).toBe(true);
|
| 159 |
});
|
| 160 |
|
| 161 |
it('stays hidden on tablet/desktop and landscape phone', () => {
|
tests/ThreadSliders.test.js
CHANGED
|
@@ -82,6 +82,13 @@ describe('threadSlidersMarkup — ranges centered on defaults', () => {
|
|
| 82 |
}
|
| 83 |
});
|
| 84 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
it('places COMPARE|NAVIGATION|POINTS Spacing 0.7 within [0.4, 2.0]', () => {
|
| 86 |
const html = threadSlidersMarkup({
|
| 87 |
threadSpacing: 0.7,
|
|
|
|
| 82 |
}
|
| 83 |
});
|
| 84 |
|
| 85 |
+
it('markup includes field-info tips on each slider label', () => {
|
| 86 |
+
const html = threadSlidersMarkup();
|
| 87 |
+
expect(html.match(/class="field-info-btn"/g)?.length).toBe(5);
|
| 88 |
+
expect(html).toContain('data-field-info="Gap between threads."');
|
| 89 |
+
expect(html).toContain('data-field-info="Peak height."');
|
| 90 |
+
});
|
| 91 |
+
|
| 92 |
it('places COMPARE|NAVIGATION|POINTS Spacing 0.7 within [0.4, 2.0]', () => {
|
| 93 |
const html = threadSlidersMarkup({
|
| 94 |
threadSpacing: 0.7,
|
tests/TouchControls.test.js
CHANGED
|
@@ -37,6 +37,15 @@ describe('isUiTouchTarget', () => {
|
|
| 37 |
expect(isUiTouchTarget(child)).toBe(true);
|
| 38 |
});
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
it('allows bare canvas', () => {
|
| 41 |
const canvas = { tagName: 'CANVAS', closest: () => null };
|
| 42 |
expect(isUiTouchTarget(canvas)).toBe(false);
|
|
|
|
| 37 |
expect(isUiTouchTarget(child)).toBe(true);
|
| 38 |
});
|
| 39 |
|
| 40 |
+
it('treats field-info tip as UI', () => {
|
| 41 |
+
const tip = {
|
| 42 |
+
className: 'field-info-tip',
|
| 43 |
+
tagName: 'DIV',
|
| 44 |
+
closest: (sel) => (sel.includes('.field-info-tip') ? tip : null),
|
| 45 |
+
};
|
| 46 |
+
expect(isUiTouchTarget(tip)).toBe(true);
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
it('allows bare canvas', () => {
|
| 50 |
const canvas = { tagName: 'CANVAS', closest: () => null };
|
| 51 |
expect(isUiTouchTarget(canvas)).toBe(false);
|
tests/fieldInfo.test.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect } from 'vitest';
|
| 2 |
+
import {
|
| 3 |
+
FIELD_INFO,
|
| 4 |
+
MAX_FIELD_INFO_LEN,
|
| 5 |
+
escapeHtmlAttr,
|
| 6 |
+
infoTipMarkup,
|
| 7 |
+
resolveFieldInfoClick,
|
| 8 |
+
} from '../src/ui/fieldInfo.js';
|
| 9 |
+
|
| 10 |
+
describe('FIELD_INFO catalog', () => {
|
| 11 |
+
it('covers every editable control key with short English copy', () => {
|
| 12 |
+
const required = [
|
| 13 |
+
'wordA', 'wordB', 'wordC',
|
| 14 |
+
'compareTokens', 'dimSort',
|
| 15 |
+
'saeToggle', 'saeHidden', 'saeK', 'saeEpochs', 'saeLr', 'saeBatch',
|
| 16 |
+
'spacingX', 'vectorDistY', 'amplitudeY', 'lengthZ', 'thickness',
|
| 17 |
+
'vizFilter', 'colorPos', 'colorZero', 'colorNeg', 'zeroCoverage', 'labelsToggle',
|
| 18 |
+
];
|
| 19 |
+
for (const key of required) {
|
| 20 |
+
expect(FIELD_INFO[key], key).toBeTruthy();
|
| 21 |
+
expect(FIELD_INFO[key].length, key).toBeLessThanOrEqual(MAX_FIELD_INFO_LEN);
|
| 22 |
+
expect(FIELD_INFO[key]).not.toMatch(/[\n\r\t]/);
|
| 23 |
+
expect(FIELD_INFO[key]).toMatch(/[A-Za-z]/);
|
| 24 |
+
}
|
| 25 |
+
});
|
| 26 |
+
});
|
| 27 |
+
|
| 28 |
+
describe('escapeHtmlAttr / infoTipMarkup', () => {
|
| 29 |
+
it('escapes quotes and angle brackets for safe attributes', () => {
|
| 30 |
+
expect(escapeHtmlAttr('a"b<c>')).toBe('a"b<c>');
|
| 31 |
+
});
|
| 32 |
+
|
| 33 |
+
it('emits a compact i button with data-field-info', () => {
|
| 34 |
+
const html = infoTipMarkup('Gap between threads.');
|
| 35 |
+
expect(html).toContain('class="field-info-btn"');
|
| 36 |
+
expect(html).toContain('data-field-info="Gap between threads."');
|
| 37 |
+
expect(html).toMatch(/>i</);
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
it('returns empty string for blank text', () => {
|
| 41 |
+
expect(infoTipMarkup('')).toBe('');
|
| 42 |
+
expect(infoTipMarkup(' ')).toBe('');
|
| 43 |
+
});
|
| 44 |
+
});
|
| 45 |
+
|
| 46 |
+
describe('resolveFieldInfoClick', () => {
|
| 47 |
+
const btnA = { id: 'a' };
|
| 48 |
+
const btnB = { id: 'b' };
|
| 49 |
+
|
| 50 |
+
it('opens when clicking an info button with none open', () => {
|
| 51 |
+
expect(resolveFieldInfoClick(null, btnA)).toEqual({ action: 'open', button: btnA });
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
it('closes when clicking the same open info button', () => {
|
| 55 |
+
expect(resolveFieldInfoClick(btnA, btnA)).toEqual({ action: 'close' });
|
| 56 |
+
});
|
| 57 |
+
|
| 58 |
+
it('switches when clicking a different info button', () => {
|
| 59 |
+
expect(resolveFieldInfoClick(btnA, btnB)).toEqual({ action: 'open', button: btnB });
|
| 60 |
+
});
|
| 61 |
+
|
| 62 |
+
it('closes on outside click while a tip is open', () => {
|
| 63 |
+
expect(resolveFieldInfoClick(btnA, null)).toEqual({ action: 'close' });
|
| 64 |
+
});
|
| 65 |
+
|
| 66 |
+
it('no-ops on outside click when nothing is open', () => {
|
| 67 |
+
expect(resolveFieldInfoClick(null, null)).toEqual({ action: 'noop' });
|
| 68 |
+
});
|
| 69 |
+
});
|
tests/visualizationControls.test.js
CHANGED
|
@@ -152,6 +152,9 @@ describe('Visualization panel collapse tab', () => {
|
|
| 152 |
expect(html).toContain('Zero coverage');
|
| 153 |
expect(html).toContain('viz-labels-toggle');
|
| 154 |
expect(html).toContain('Hide labels');
|
|
|
|
|
|
|
|
|
|
| 155 |
});
|
| 156 |
|
| 157 |
it('collapse tab is a sibling of viz-panel-body (outside card chrome)', () => {
|
|
|
|
| 152 |
expect(html).toContain('Zero coverage');
|
| 153 |
expect(html).toContain('viz-labels-toggle');
|
| 154 |
expect(html).toContain('Hide labels');
|
| 155 |
+
expect(html).toContain('field-info-btn');
|
| 156 |
+
expect(html).toContain('data-field-info="Which signs show."');
|
| 157 |
+
expect(html).toContain('data-field-info="% held at zero."');
|
| 158 |
});
|
| 159 |
|
| 160 |
it('collapse tab is a sibling of viz-panel-body (outside card chrome)', () => {
|