hbauzan Cursor commited on
Commit
aaa664a
·
1 Parent(s): 182139f

feat(ui): field-info tips and retire landscape gate

Browse files

Add 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 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 suave (portrait phone) — no lock, no pause
191
- - **Problema**: Forzar `orientation.lock('landscape')` falla en iOS Safari; un blocker duro atrapa al usuario en portrait.
192
- - **Solución Obligatoria**: Overlay soft solo en phone portrait (`width 768` y `height > width`); dismiss escribe `sessionStorage` y no re-spamea en la sesión; tablet = desktop (sin overlay). El render loop **nunca** se pausa por portrait.
193
- - **Invariante**: landscape-first sugerido, nunca bloqueante.
 
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: 1.4rem;
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&#10;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&#10;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
- * Soft portrait overlay for phone viewports (roadmap D13 / Etapa B).
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
- * Pure visibility decision for tests / wiring.
46
- * @param {{ isPortrait: boolean, dismissed: boolean, isMobile: boolean }} state
47
  */
48
- export function shouldShowLandscapeGate(state) {
49
- if (!state.isMobile) return false;
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('role', 'dialog');
83
- this.overlay.setAttribute('aria-modal', 'false');
84
- this.overlay.setAttribute('aria-labelledby', 'landscape-gate-title');
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 !this.overlay.classList.contains('hidden');
116
  }
117
 
118
  refresh() {
119
- const show = shouldShowLandscapeGate({
120
- isMobile: this._isMobile(),
121
- isPortrait: this._isPortrait(),
122
- dismissed: wasLandscapeGateDismissed(this._session),
123
- });
124
- this.overlay.classList.toggle('hidden', !show);
125
  }
126
 
127
  dismiss() {
128
- dismissLandscapeGate(this._session);
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
- <button type="button" id="${p}-btn-sae-toggle" class="btn-sae-toggle" aria-pressed="false">
24
- Clean/Denoise (SAE)
25
- </button>
 
 
 
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
- <button type="button" id="viz-labels-toggle" class="viz-labels-toggle" aria-pressed="${s.labelsVisible ? 'true' : 'false'}" title="Show or hide floating thread labels">
161
- ${labelsToggleButtonText(s.labelsVisible)}
162
- </button>
 
 
 
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, '&amp;')
45
+ .replace(/"/g, '&quot;')
46
+ .replace(/</g, '&lt;')
47
+ .replace(/>/g, '&gt;');
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 only for mobile portrait not yet dismissed', () => {
120
- expect(shouldShowLandscapeGate({ isMobile: true, isPortrait: true, dismissed: false })).toBe(true);
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 and dismisses without trapping', () => {
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&quot;b&lt;c&gt;');
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)', () => {