hbauzan Cursor commited on
Commit
df81806
·
1 Parent(s): f5c0476

fix(ui): compact mobile chrome and Visualization bottom sheet

Browse files

Shrink navbar/HUD on phone and re-host Visualization on the app root as an upward sheet so it is reachable outside the transformed right dock.

Co-authored-by: Cursor <cursoragent@cursor.com>

.agents/skills/dev-protocol/lessons-learned.md CHANGED
@@ -211,6 +211,8 @@ User-editable hex anchors replace the former fixed dual ramp (no product mid-sto
211
  - **Color anchors**: user hex +1 / 0 / −1 replace fixed mid-stop ramps; lerp `0↔+1` and `0↔−1`. POINTS shader uses uniforms `uColorPos` / `uColorNeg` / `uColorZero` (never hardcode ramp in GLSL).
212
  - **Zero coverage %**: slider **below Colors, above Reset**. Remaps `|t|` so zero color occupies `coverage` of the ± range before lerp (`remapAbsTWithZeroCoverage`); CPU + `uZeroCoverage` share math. Cap 90% so ±1 remains reachable. Persist `vl3d.viz.*` including `zeroCoverage`.
213
  - **Collapse tab**: Visualization has its own edge tab (dock-tab affordance) independent of the right-dock host; key `vl3d.viz.panelCollapsed`. **Chrome del card va en `.viz-panel-body`, no en el host** — el tab es hermano del body (afuera del glass), igual que `.dock-tab` vs `.dock-body`. No poner `overflow:hidden` / `max-width:280px` / border en el host o el tab queda “adentro”.
 
 
214
  - **SemVer**: if `main` already shipped a MINOR (e.g. groups `1.7.0`), the next capability must take the **next** MINOR (`1.8.0`) — never reuse a version number already on `main`.
215
  - **Invariante**: filter-on-normalized-t; F4 = geometry not points-only; anchors always drive ramp in v1; no backend for viz controls.
216
 
 
211
  - **Color anchors**: user hex +1 / 0 / −1 replace fixed mid-stop ramps; lerp `0↔+1` and `0↔−1`. POINTS shader uses uniforms `uColorPos` / `uColorNeg` / `uColorZero` (never hardcode ramp in GLSL).
212
  - **Zero coverage %**: slider **below Colors, above Reset**. Remaps `|t|` so zero color occupies `coverage` of the ± range before lerp (`remapAbsTWithZeroCoverage`); CPU + `uZeroCoverage` share math. Cap 90% so ±1 remains reachable. Persist `vl3d.viz.*` including `zeroCoverage`.
213
  - **Collapse tab**: Visualization has its own edge tab (dock-tab affordance) independent of the right-dock host; key `vl3d.viz.panelCollapsed`. **Chrome del card va en `.viz-panel-body`, no en el host** — el tab es hermano del body (afuera del glass), igual que `.dock-tab` vs `.dock-body`. No poner `overflow:hidden` / `max-width:280px` / border en el host o el tab queda “adentro”.
214
+ - **Mobile sheet**: en `≤768px` el panel **sale del right dock** (`resolveVisualizationMountParent` → app root). Un ancestor con `transform` (dock colapsado) atrapa `position:fixed`. Layout `data-viz-layout="sheet"`: handle arriba, body abre hacia arriba; glifos ▲/▼. Touch blocklist incluye `#visualization-controls-container`.
215
+ - **Mobile chrome density**: navbar tabs ~28px / ~0.62rem; HUD strip ~26px / ~0.58rem single-line — no forzar 44px en chrome decorativo (inputs/CTAs del panel sí mantienen 44px / 16px iOS zoom guard).
216
  - **SemVer**: if `main` already shipped a MINOR (e.g. groups `1.7.0`), the next capability must take the **next** MINOR (`1.8.0`) — never reuse a version number already on `main`.
217
  - **Invariante**: filter-on-normalized-t; F4 = geometry not points-only; anchors always drive ramp in v1; no backend for viz controls.
218
 
src/main.js CHANGED
@@ -29,6 +29,9 @@ import {
29
  wireVisualizationControls,
30
  syncVisualizationControlsFromConfig,
31
  readVisualizationPanelCollapsed,
 
 
 
32
  } from './ui/VisualizationControls.js';
33
  import {
34
  loadVisualizationSettings,
@@ -46,7 +49,7 @@ import {
46
  formatSaeTrainProgress,
47
  } from './ui/saeControlsDefaults.js';
48
  import { ComparePanel, getCompareBootstrap } from './ui/ComparePanel.js';
49
- import { CollapsibleDock, isMobileViewport } from './ui/CollapsibleDock.js';
50
  import { LandscapeGate } from './ui/LandscapeGate.js';
51
  import { TouchControls } from './ui/TouchControls.js';
52
  import {
@@ -213,26 +216,67 @@ class VHectorLabApp {
213
  }
214
 
215
  /**
216
- * Visualization panel (sign filter + color anchors) below Spatial Controls.
 
217
  */
218
  mountVisualizationControlsUI() {
219
  this.vizConfig = loadVisualizationSettings();
220
  const storage = typeof localStorage !== 'undefined' ? localStorage : null;
 
 
221
  const collapsed = readVisualizationPanelCollapsed(storage, {
222
- isMobile: isMobileViewport(),
223
  });
224
  const wrapper = document.createElement('div');
225
- wrapper.innerHTML = visualizationControlsMarkup(this.vizConfig, { collapsed });
226
  const vizEl = wrapper.firstElementChild;
227
  this.vizEl = vizEl;
228
- // Below sliders, above AxisGizmo (V1).
229
- this.rightDock.body.insertBefore(vizEl, this.axisGizmo.container);
230
 
231
  wireVisualizationControls(vizEl, this.vizConfig, () => {
232
  this.threadLabels.setVisible(this.vizConfig.labelsVisible);
233
  this.refreshRender();
234
  });
235
  this.threadLabels.setVisible(this.vizConfig.labelsVisible);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  }
237
 
238
  /**
 
29
  wireVisualizationControls,
30
  syncVisualizationControlsFromConfig,
31
  readVisualizationPanelCollapsed,
32
+ vizPanelLayoutForViewport,
33
+ resolveVisualizationMountParent,
34
+ setVisualizationPanelLayout,
35
  } from './ui/VisualizationControls.js';
36
  import {
37
  loadVisualizationSettings,
 
49
  formatSaeTrainProgress,
50
  } from './ui/saeControlsDefaults.js';
51
  import { ComparePanel, getCompareBootstrap } from './ui/ComparePanel.js';
52
+ import { CollapsibleDock, isMobileViewport, MOBILE_MQ } from './ui/CollapsibleDock.js';
53
  import { LandscapeGate } from './ui/LandscapeGate.js';
54
  import { TouchControls } from './ui/TouchControls.js';
55
  import {
 
216
  }
217
 
218
  /**
219
+ * Visualization panel: desktop = under Spatial Controls in right dock;
220
+ * phone = bottom sheet on app root (must leave transformed dock host).
221
  */
222
  mountVisualizationControlsUI() {
223
  this.vizConfig = loadVisualizationSettings();
224
  const storage = typeof localStorage !== 'undefined' ? localStorage : null;
225
+ const mobile = isMobileViewport();
226
+ const layout = vizPanelLayoutForViewport(mobile);
227
  const collapsed = readVisualizationPanelCollapsed(storage, {
228
+ isMobile: mobile,
229
  });
230
  const wrapper = document.createElement('div');
231
+ wrapper.innerHTML = visualizationControlsMarkup(this.vizConfig, { collapsed, layout });
232
  const vizEl = wrapper.firstElementChild;
233
  this.vizEl = vizEl;
234
+ this.placeVisualizationControls(vizEl, mobile);
 
235
 
236
  wireVisualizationControls(vizEl, this.vizConfig, () => {
237
  this.threadLabels.setVisible(this.vizConfig.labelsVisible);
238
  this.refreshRender();
239
  });
240
  this.threadLabels.setVisible(this.vizConfig.labelsVisible);
241
+
242
+ if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
243
+ this._vizLayoutMq = window.matchMedia(MOBILE_MQ);
244
+ this._onVizLayoutMq = () => this.syncVisualizationPanelHost();
245
+ if (typeof this._vizLayoutMq.addEventListener === 'function') {
246
+ this._vizLayoutMq.addEventListener('change', this._onVizLayoutMq);
247
+ } else if (typeof this._vizLayoutMq.addListener === 'function') {
248
+ this._vizLayoutMq.addListener(this._onVizLayoutMq);
249
+ }
250
+ }
251
+ }
252
+
253
+ /**
254
+ * @param {HTMLElement} vizEl
255
+ * @param {boolean} [isMobile]
256
+ */
257
+ placeVisualizationControls(vizEl, isMobile = isMobileViewport()) {
258
+ const parent = resolveVisualizationMountParent({
259
+ isMobile,
260
+ dockBody: this.rightDock.body,
261
+ appRoot: this.appContainer,
262
+ });
263
+ if (isMobile) {
264
+ parent.appendChild(vizEl);
265
+ return;
266
+ }
267
+ // Below sliders, above AxisGizmo (desktop).
268
+ if (vizEl.parentElement !== parent || vizEl.nextElementSibling !== this.axisGizmo.container) {
269
+ parent.insertBefore(vizEl, this.axisGizmo.container);
270
+ }
271
+ }
272
+
273
+ /** Re-host + retarget glyphs when crossing the phone breakpoint. */
274
+ syncVisualizationPanelHost() {
275
+ if (!this.vizEl) return;
276
+ const mobile = isMobileViewport();
277
+ const layout = vizPanelLayoutForViewport(mobile);
278
+ setVisualizationPanelLayout(this.vizEl, layout);
279
+ this.placeVisualizationControls(this.vizEl, mobile);
280
  }
281
 
282
  /**
src/style.css CHANGED
@@ -1552,13 +1552,14 @@ canvas {
1552
  env(safe-area-inset-bottom) env(safe-area-inset-left);
1553
  }
1554
 
 
1555
  .glass-navbar {
1556
  height: auto;
1557
- min-height: 48px;
1558
  flex-wrap: wrap;
1559
- gap: 6px;
1560
- padding: 6px 10px;
1561
- align-items: flex-start;
1562
  }
1563
 
1564
  .navbar-brand .version-tag,
@@ -1567,12 +1568,13 @@ canvas {
1567
  }
1568
 
1569
  .title-group h1 {
1570
- font-size: 0.95rem;
 
1571
  }
1572
 
1573
  .navbar-center-controls {
1574
  flex-wrap: wrap;
1575
- gap: 6px;
1576
  order: 3;
1577
  width: 100%;
1578
  justify-content: flex-start;
@@ -1581,8 +1583,8 @@ canvas {
1581
  .view-mode-tabs,
1582
  .render-mode-tabs,
1583
  .workspace-mode-tabs {
1584
- gap: 4px;
1585
- padding: 2px;
1586
  }
1587
 
1588
  .tab-label {
@@ -1592,10 +1594,11 @@ canvas {
1592
  .mode-tab,
1593
  .view-tab,
1594
  .workspace-tab {
1595
- min-height: 44px;
1596
- min-width: 44px;
1597
- padding: 8px 10px;
1598
- font-size: 0.72rem;
 
1599
  }
1600
 
1601
  .status-indicator {
@@ -1603,8 +1606,8 @@ canvas {
1603
  }
1604
 
1605
  .status-text {
1606
- font-size: 0.65rem;
1607
- max-width: 90px;
1608
  overflow: hidden;
1609
  text-overflow: ellipsis;
1610
  white-space: nowrap;
@@ -1612,7 +1615,7 @@ canvas {
1612
 
1613
  /* Docks as overlay drawers — do not push canvas layout */
1614
  .collapsible-dock {
1615
- top: 96px;
1616
  z-index: 80;
1617
  }
1618
 
@@ -1622,7 +1625,7 @@ canvas {
1622
 
1623
  .dock-right {
1624
  right: max(8px, env(safe-area-inset-right));
1625
- bottom: max(64px, env(safe-area-inset-bottom));
1626
  }
1627
 
1628
  .collapsible-dock:not(.is-collapsed) .dock-body {
@@ -1630,14 +1633,14 @@ canvas {
1630
  }
1631
 
1632
  .dock-tab {
1633
- min-height: 44px;
1634
- min-width: 44px;
1635
- width: 44px;
1636
- flex-basis: 44px;
1637
  }
1638
 
1639
  .collapsible-dock {
1640
- --dock-tab-size: 44px;
1641
  }
1642
 
1643
  .glass-sidebar {
@@ -1686,58 +1689,132 @@ canvas {
1686
  gap: 10px;
1687
  }
1688
 
 
 
 
 
1689
  #visualization-controls-container.viz-panel {
 
 
 
 
 
 
 
1690
  width: auto;
 
 
1691
  padding: 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1692
  }
1693
 
1694
  #visualization-controls-container .viz-panel-body {
1695
- width: min(260px, calc(100vw - 64px));
1696
- padding: 12px;
 
 
 
1697
  gap: 10px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1698
  }
1699
 
1700
  .slider-group input[type="range"] {
1701
  min-height: 44px;
1702
  }
1703
 
 
1704
  .glass-hud {
1705
- left: max(8px, env(safe-area-inset-left));
1706
- right: max(8px, env(safe-area-inset-right));
1707
- bottom: max(8px, env(safe-area-inset-bottom));
1708
  height: auto;
1709
- min-height: 44px;
1710
- padding: 8px 12px;
1711
- font-size: 0.7rem;
1712
- flex-wrap: wrap;
1713
- gap: 4px 8px;
 
1714
  }
1715
 
1716
- .hud-left, .hud-right {
1717
- flex: 1 1 40%;
 
1718
  }
1719
 
1720
  .hud-center {
1721
- flex: 1 1 100%;
1722
- order: 3;
1723
- justify-content: flex-start;
 
 
 
 
 
1724
  }
1725
 
1726
  .hud-badge {
1727
- max-width: 36vw;
 
 
1728
  }
1729
 
1730
  .hud-token-text {
1731
- max-width: 40vw;
1732
  }
1733
 
1734
  .hud-highlight {
1735
- font-size: 0.72rem;
1736
  }
1737
 
1738
  #axis-gizmo-container {
1739
- width: 72px;
1740
- height: 72px;
1741
  }
1742
 
1743
  #compare-panel .compare-cosine-list {
 
1552
  env(safe-area-inset-bottom) env(safe-area-inset-left);
1553
  }
1554
 
1555
+ /* Compact top chrome — denser than 44px HIG so the canvas keeps air */
1556
  .glass-navbar {
1557
  height: auto;
1558
+ min-height: 36px;
1559
  flex-wrap: wrap;
1560
+ gap: 4px;
1561
+ padding: 4px 8px;
1562
+ align-items: center;
1563
  }
1564
 
1565
  .navbar-brand .version-tag,
 
1568
  }
1569
 
1570
  .title-group h1 {
1571
+ font-size: 0.82rem;
1572
+ letter-spacing: 0.5px;
1573
  }
1574
 
1575
  .navbar-center-controls {
1576
  flex-wrap: wrap;
1577
+ gap: 4px;
1578
  order: 3;
1579
  width: 100%;
1580
  justify-content: flex-start;
 
1583
  .view-mode-tabs,
1584
  .render-mode-tabs,
1585
  .workspace-mode-tabs {
1586
+ gap: 2px;
1587
+ padding: 1px;
1588
  }
1589
 
1590
  .tab-label {
 
1594
  .mode-tab,
1595
  .view-tab,
1596
  .workspace-tab {
1597
+ min-height: 28px;
1598
+ min-width: 0;
1599
+ padding: 3px 7px;
1600
+ font-size: 0.62rem;
1601
+ border-radius: 4px;
1602
  }
1603
 
1604
  .status-indicator {
 
1606
  }
1607
 
1608
  .status-text {
1609
+ font-size: 0.58rem;
1610
+ max-width: 72px;
1611
  overflow: hidden;
1612
  text-overflow: ellipsis;
1613
  white-space: nowrap;
 
1615
 
1616
  /* Docks as overlay drawers — do not push canvas layout */
1617
  .collapsible-dock {
1618
+ top: 72px;
1619
  z-index: 80;
1620
  }
1621
 
 
1625
 
1626
  .dock-right {
1627
  right: max(8px, env(safe-area-inset-right));
1628
+ bottom: max(56px, env(safe-area-inset-bottom));
1629
  }
1630
 
1631
  .collapsible-dock:not(.is-collapsed) .dock-body {
 
1633
  }
1634
 
1635
  .dock-tab {
1636
+ min-height: 40px;
1637
+ min-width: 36px;
1638
+ width: 36px;
1639
+ flex-basis: 36px;
1640
  }
1641
 
1642
  .collapsible-dock {
1643
+ --dock-tab-size: 36px;
1644
  }
1645
 
1646
  .glass-sidebar {
 
1689
  gap: 10px;
1690
  }
1691
 
1692
+ /*
1693
+ Visualization bottom sheet (phone): host on app root, open upward.
1694
+ data-viz-layout="sheet" is set from JS; CSS also scopes under max-width.
1695
+ */
1696
  #visualization-controls-container.viz-panel {
1697
+ position: fixed;
1698
+ left: max(8px, env(safe-area-inset-left));
1699
+ right: max(8px, env(safe-area-inset-right));
1700
+ bottom: calc(max(6px, env(safe-area-inset-bottom)) + 30px);
1701
+ z-index: 90;
1702
+ flex-direction: column;
1703
+ align-items: stretch;
1704
  width: auto;
1705
+ max-width: none;
1706
+ min-width: 0;
1707
  padding: 0;
1708
+ gap: 0;
1709
+ transition: transform 250ms ease;
1710
+ }
1711
+
1712
+ #visualization-controls-container .viz-panel-tab {
1713
+ flex: 0 0 26px;
1714
+ width: 100%;
1715
+ min-height: 26px;
1716
+ height: 26px;
1717
+ margin: 0;
1718
+ padding: 0;
1719
+ align-self: stretch;
1720
+ border-radius: 10px 10px 0 0;
1721
+ border: 1px solid var(--panel-border);
1722
+ border-bottom: none;
1723
+ font-size: 0.7rem;
1724
  }
1725
 
1726
  #visualization-controls-container .viz-panel-body {
1727
+ width: 100%;
1728
+ max-height: min(42dvh, 320px);
1729
+ overflow-x: hidden;
1730
+ overflow-y: auto;
1731
+ padding: 10px 12px;
1732
  gap: 10px;
1733
+ border-radius: 0 0 10px 10px;
1734
+ }
1735
+
1736
+ #visualization-controls-container.is-collapsed {
1737
+ width: auto;
1738
+ min-width: 0;
1739
+ max-width: none;
1740
+ }
1741
+
1742
+ #visualization-controls-container.is-collapsed .viz-panel-body {
1743
+ position: absolute;
1744
+ left: 0;
1745
+ right: 0;
1746
+ bottom: 0;
1747
+ opacity: 0;
1748
+ pointer-events: none;
1749
+ transform: translateY(10px);
1750
+ width: auto;
1751
+ height: 0;
1752
+ max-height: 0;
1753
+ padding: 0;
1754
+ overflow: hidden;
1755
+ border: none;
1756
+ box-shadow: none;
1757
+ background: transparent;
1758
+ }
1759
+
1760
+ #visualization-controls-container.is-collapsed .viz-panel-tab {
1761
+ margin: 0;
1762
+ border-radius: 10px;
1763
+ border: 1px solid var(--panel-border);
1764
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
1765
  }
1766
 
1767
  .slider-group input[type="range"] {
1768
  min-height: 44px;
1769
  }
1770
 
1771
+ /* Delicate bottom telemetry strip */
1772
  .glass-hud {
1773
+ left: max(6px, env(safe-area-inset-left));
1774
+ right: max(6px, env(safe-area-inset-right));
1775
+ bottom: max(4px, env(safe-area-inset-bottom));
1776
  height: auto;
1777
+ min-height: 26px;
1778
+ padding: 3px 8px;
1779
+ font-size: 0.58rem;
1780
+ flex-wrap: nowrap;
1781
+ gap: 6px;
1782
+ border-radius: 8px;
1783
  }
1784
 
1785
+ .hud-left, .hud-right, .hud-center {
1786
+ flex: 0 1 auto;
1787
+ gap: 4px;
1788
  }
1789
 
1790
  .hud-center {
1791
+ flex: 1 1 auto;
1792
+ order: 0;
1793
+ justify-content: center;
1794
+ min-width: 0;
1795
+ }
1796
+
1797
+ .hud-label {
1798
+ font-size: 0.52rem;
1799
  }
1800
 
1801
  .hud-badge {
1802
+ max-width: 28vw;
1803
+ padding: 1px 5px;
1804
+ font-size: 0.55rem;
1805
  }
1806
 
1807
  .hud-token-text {
1808
+ max-width: 28vw;
1809
  }
1810
 
1811
  .hud-highlight {
1812
+ font-size: 0.58rem;
1813
  }
1814
 
1815
  #axis-gizmo-container {
1816
+ width: 64px;
1817
+ height: 64px;
1818
  }
1819
 
1820
  #compare-panel .compare-cosine-list {
src/ui/TouchControls.js CHANGED
@@ -11,6 +11,7 @@ const UI_BLOCK_SELECTOR = [
11
  '#right-dock',
12
  '#bottom-hud',
13
  '#top-navbar',
 
14
  '#landscape-gate',
15
  '#touch-controls',
16
  '.glass-modal',
 
11
  '#right-dock',
12
  '#bottom-hud',
13
  '#top-navbar',
14
+ '#visualization-controls-container',
15
  '#landscape-gate',
16
  '#touch-controls',
17
  '.glass-modal',
src/ui/VisualizationControls.js CHANGED
@@ -1,6 +1,7 @@
1
  /**
2
- * Visualization panel: sign filter + divergent color anchors (right dock).
3
- * Includes an edge collapse tab (same affordance as CollapsibleDock).
 
4
  */
5
 
6
  import {
@@ -23,22 +24,61 @@ import {
23
 
24
  export const VIZ_PANEL_COLLAPSE_KEY = `${VIZ_STORAGE_PREFIX}panelCollapsed`;
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  /**
27
  * @param {boolean} collapsed
 
28
  * @returns {string}
29
  */
30
- export function vizPanelTabGlyph(collapsed) {
 
31
  return collapsed ? '◀' : '▶';
32
  }
33
 
34
  /**
35
  * @param {boolean} collapsed
 
36
  * @returns {string}
37
  */
38
- export function vizPanelTabLabel(collapsed) {
 
 
 
39
  return collapsed ? 'Expand Visualization panel' : 'Collapse Visualization panel';
40
  }
41
 
 
 
 
 
 
 
 
 
 
42
  /**
43
  * Button label for thread-labels visibility toggle.
44
  * @param {boolean} labelsVisible
@@ -50,19 +90,20 @@ export function labelsToggleButtonText(labelsVisible) {
50
 
51
  /**
52
  * @param {import('./visualizationControlsDefaults.js').VisualizationSettings} [config]
53
- * @param {{ collapsed?: boolean }} [opts]
54
  * @returns {string}
55
  */
56
  export function visualizationControlsMarkup(config = DEFAULT_VISUALIZATION_SETTINGS, opts = {}) {
57
  const s = resolveVisualizationSettings(config);
58
  const collapsed = opts.collapsed === true;
 
59
  const checked = (mode) => (s.vizFilterMode === mode ? 'checked' : '');
60
  const collapsedClass = collapsed ? ' is-collapsed' : '';
61
- const glyph = vizPanelTabGlyph(collapsed);
62
- const label = vizPanelTabLabel(collapsed);
63
 
64
  return `
65
- <div id="visualization-controls-container" class="section-card viz-panel${collapsedClass}">
66
  <button type="button" class="viz-panel-tab dock-tab" aria-expanded="${collapsed ? 'false' : 'true'}" aria-controls="viz-panel-body" title="${label}" aria-label="${label}">${glyph}</button>
67
  <div id="viz-panel-body" class="viz-panel-body">
68
  <h3 class="sliders-title">Visualization</h3>
@@ -164,17 +205,30 @@ export function syncVisualizationControlsFromConfig(container, config) {
164
  export function setVisualizationPanelCollapsed(container, collapsed) {
165
  if (!container) return;
166
  const next = Boolean(collapsed);
 
167
  container.classList.toggle('is-collapsed', next);
168
  const tab = container.querySelector('.viz-panel-tab');
169
  if (tab) {
170
- const label = vizPanelTabLabel(next);
171
  tab.setAttribute('aria-expanded', next ? 'false' : 'true');
172
  tab.setAttribute('aria-label', label);
173
  tab.setAttribute('title', label);
174
- tab.textContent = vizPanelTabGlyph(next);
175
  }
176
  }
177
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  /**
179
  * Wire filter radios, hex/swatch inputs, Reset, and edge collapse tab.
180
  * Mutates `config` in place; persists to localStorage; calls onChange.
 
1
  /**
2
+ * Visualization panel: sign filter + divergent color anchors.
3
+ * Desktop: edge collapse tab in the right dock (sibling outside card chrome).
4
+ * Phone: bottom sheet on app root (open upward; must leave transformed dock).
5
  */
6
 
7
  import {
 
24
 
25
  export const VIZ_PANEL_COLLAPSE_KEY = `${VIZ_STORAGE_PREFIX}panelCollapsed`;
26
 
27
+ /** @typedef {'edge' | 'sheet'} VizPanelLayout */
28
+
29
+ /**
30
+ * Desktop = side edge tab; phone = bottom sheet (open upward).
31
+ * @param {boolean} isMobile
32
+ * @returns {VizPanelLayout}
33
+ */
34
+ export function vizPanelLayoutForViewport(isMobile) {
35
+ return isMobile ? 'sheet' : 'edge';
36
+ }
37
+
38
+ /**
39
+ * Where to mount the Visualization host (sheet must leave the right dock —
40
+ * a transformed ancestor traps `position: fixed`).
41
+ * @param {{ isMobile: boolean, dockBody: HTMLElement, appRoot: HTMLElement }} opts
42
+ * @returns {HTMLElement}
43
+ */
44
+ export function resolveVisualizationMountParent(opts) {
45
+ if (!opts || !opts.dockBody || !opts.appRoot) {
46
+ throw new Error('resolveVisualizationMountParent requires dockBody and appRoot');
47
+ }
48
+ return opts.isMobile ? opts.appRoot : opts.dockBody;
49
+ }
50
+
51
  /**
52
  * @param {boolean} collapsed
53
+ * @param {VizPanelLayout} [layout='edge']
54
  * @returns {string}
55
  */
56
+ export function vizPanelTabGlyph(collapsed, layout = 'edge') {
57
+ if (layout === 'sheet') return collapsed ? '▲' : '▼';
58
  return collapsed ? '◀' : '▶';
59
  }
60
 
61
  /**
62
  * @param {boolean} collapsed
63
+ * @param {VizPanelLayout} [layout='edge']
64
  * @returns {string}
65
  */
66
+ export function vizPanelTabLabel(collapsed, layout = 'edge') {
67
+ if (layout === 'sheet') {
68
+ return collapsed ? 'Expand Visualization sheet' : 'Collapse Visualization sheet';
69
+ }
70
  return collapsed ? 'Expand Visualization panel' : 'Collapse Visualization panel';
71
  }
72
 
73
+ /**
74
+ * @param {HTMLElement | null | undefined} container
75
+ * @returns {VizPanelLayout}
76
+ */
77
+ export function readVizPanelLayout(container) {
78
+ const raw = container && container.dataset ? container.dataset.vizLayout : null;
79
+ return raw === 'sheet' ? 'sheet' : 'edge';
80
+ }
81
+
82
  /**
83
  * Button label for thread-labels visibility toggle.
84
  * @param {boolean} labelsVisible
 
90
 
91
  /**
92
  * @param {import('./visualizationControlsDefaults.js').VisualizationSettings} [config]
93
+ * @param {{ collapsed?: boolean, layout?: VizPanelLayout }} [opts]
94
  * @returns {string}
95
  */
96
  export function visualizationControlsMarkup(config = DEFAULT_VISUALIZATION_SETTINGS, opts = {}) {
97
  const s = resolveVisualizationSettings(config);
98
  const collapsed = opts.collapsed === true;
99
+ const layout = opts.layout === 'sheet' ? 'sheet' : 'edge';
100
  const checked = (mode) => (s.vizFilterMode === mode ? 'checked' : '');
101
  const collapsedClass = collapsed ? ' is-collapsed' : '';
102
+ const glyph = vizPanelTabGlyph(collapsed, layout);
103
+ const label = vizPanelTabLabel(collapsed, layout);
104
 
105
  return `
106
+ <div id="visualization-controls-container" class="section-card viz-panel${collapsedClass}" data-viz-layout="${layout}">
107
  <button type="button" class="viz-panel-tab dock-tab" aria-expanded="${collapsed ? 'false' : 'true'}" aria-controls="viz-panel-body" title="${label}" aria-label="${label}">${glyph}</button>
108
  <div id="viz-panel-body" class="viz-panel-body">
109
  <h3 class="sliders-title">Visualization</h3>
 
205
  export function setVisualizationPanelCollapsed(container, collapsed) {
206
  if (!container) return;
207
  const next = Boolean(collapsed);
208
+ const layout = readVizPanelLayout(container);
209
  container.classList.toggle('is-collapsed', next);
210
  const tab = container.querySelector('.viz-panel-tab');
211
  if (tab) {
212
+ const label = vizPanelTabLabel(next, layout);
213
  tab.setAttribute('aria-expanded', next ? 'false' : 'true');
214
  tab.setAttribute('aria-label', label);
215
  tab.setAttribute('title', label);
216
+ tab.textContent = vizPanelTabGlyph(next, layout);
217
  }
218
  }
219
 
220
+ /**
221
+ * Sync layout attribute + tab glyphs for the current viewport mode.
222
+ * @param {HTMLElement} container
223
+ * @param {VizPanelLayout} layout
224
+ */
225
+ export function setVisualizationPanelLayout(container, layout) {
226
+ if (!container) return;
227
+ const next = layout === 'sheet' ? 'sheet' : 'edge';
228
+ container.dataset.vizLayout = next;
229
+ setVisualizationPanelCollapsed(container, container.classList.contains('is-collapsed'));
230
+ }
231
+
232
  /**
233
  * Wire filter radios, hex/swatch inputs, Reset, and edge collapse tab.
234
  * Mutates `config` in place; persists to localStorage; calls onChange.
tests/TouchControls.test.js CHANGED
@@ -27,6 +27,16 @@ describe('isUiTouchTarget', () => {
27
  expect(isUiTouchTarget(child)).toBe(true);
28
  });
29
 
 
 
 
 
 
 
 
 
 
 
30
  it('allows bare canvas', () => {
31
  const canvas = { tagName: 'CANVAS', closest: () => null };
32
  expect(isUiTouchTarget(canvas)).toBe(false);
 
27
  expect(isUiTouchTarget(child)).toBe(true);
28
  });
29
 
30
+ it('treats Visualization sheet as UI', () => {
31
+ const sheet = {
32
+ id: 'visualization-controls-container',
33
+ tagName: 'DIV',
34
+ closest: (sel) => (sel.includes('#visualization-controls-container') ? sheet : null),
35
+ };
36
+ const child = { tagName: 'BUTTON', closest: (sel) => sheet.closest(sel) };
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);
tests/visualizationControls.test.js CHANGED
@@ -18,6 +18,10 @@ import {
18
  import {
19
  visualizationControlsMarkup,
20
  setVisualizationPanelCollapsed,
 
 
 
 
21
  } from '../src/ui/VisualizationControls.js';
22
  import {
23
  NEAR_ZERO_EPS,
@@ -124,6 +128,7 @@ describe('Visualization panel collapse tab', () => {
124
  it('markup includes edge tab and aria-expanded', () => {
125
  const html = visualizationControlsMarkup(DEFAULT_VISUALIZATION_SETTINGS, { collapsed: false });
126
  expect(html).toContain('viz-panel-tab');
 
127
  expect(html).toContain('aria-expanded="true"');
128
  expect(html).toContain('▶');
129
  expect(html).toContain('viz-zero-coverage-slider');
@@ -154,6 +159,35 @@ describe('Visualization panel collapse tab', () => {
154
  expect(html).toContain('◀');
155
  });
156
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  it('setVisualizationPanelCollapsed toggles class and glyph', () => {
158
  const classList = new Set(['section-card', 'viz-panel']);
159
  const tab = {
@@ -162,6 +196,7 @@ describe('Visualization panel collapse tab', () => {
162
  setAttribute(k, v) { this.attrs[k] = String(v); },
163
  };
164
  const el = {
 
165
  classList: {
166
  toggle: (cls, force) => {
167
  if (force) classList.add(cls);
@@ -179,6 +214,29 @@ describe('Visualization panel collapse tab', () => {
179
  expect(classList.has('is-collapsed')).toBe(false);
180
  expect(tab.textContent).toBe('▶');
181
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  });
183
 
184
  describe('shouldShowActivation / filter helpers', () => {
 
18
  import {
19
  visualizationControlsMarkup,
20
  setVisualizationPanelCollapsed,
21
+ setVisualizationPanelLayout,
22
+ vizPanelTabGlyph,
23
+ vizPanelLayoutForViewport,
24
+ resolveVisualizationMountParent,
25
  } from '../src/ui/VisualizationControls.js';
26
  import {
27
  NEAR_ZERO_EPS,
 
128
  it('markup includes edge tab and aria-expanded', () => {
129
  const html = visualizationControlsMarkup(DEFAULT_VISUALIZATION_SETTINGS, { collapsed: false });
130
  expect(html).toContain('viz-panel-tab');
131
+ expect(html).toContain('data-viz-layout="edge"');
132
  expect(html).toContain('aria-expanded="true"');
133
  expect(html).toContain('▶');
134
  expect(html).toContain('viz-zero-coverage-slider');
 
159
  expect(html).toContain('◀');
160
  });
161
 
162
+ it('sheet layout uses up/down glyphs and layout attribute', () => {
163
+ expect(vizPanelLayoutForViewport(true)).toBe('sheet');
164
+ expect(vizPanelLayoutForViewport(false)).toBe('edge');
165
+ expect(vizPanelTabGlyph(true, 'sheet')).toBe('▲');
166
+ expect(vizPanelTabGlyph(false, 'sheet')).toBe('▼');
167
+ const html = visualizationControlsMarkup(DEFAULT_VISUALIZATION_SETTINGS, {
168
+ collapsed: true,
169
+ layout: 'sheet',
170
+ });
171
+ expect(html).toContain('data-viz-layout="sheet"');
172
+ expect(html).toContain('▲');
173
+ expect(html).toContain('Expand Visualization sheet');
174
+ });
175
+
176
+ it('resolveVisualizationMountParent leaves the dock on phone', () => {
177
+ const dockBody = { id: 'dock' };
178
+ const appRoot = { id: 'app' };
179
+ expect(resolveVisualizationMountParent({
180
+ isMobile: true,
181
+ dockBody,
182
+ appRoot,
183
+ })).toBe(appRoot);
184
+ expect(resolveVisualizationMountParent({
185
+ isMobile: false,
186
+ dockBody,
187
+ appRoot,
188
+ })).toBe(dockBody);
189
+ });
190
+
191
  it('setVisualizationPanelCollapsed toggles class and glyph', () => {
192
  const classList = new Set(['section-card', 'viz-panel']);
193
  const tab = {
 
196
  setAttribute(k, v) { this.attrs[k] = String(v); },
197
  };
198
  const el = {
199
+ dataset: { vizLayout: 'edge' },
200
  classList: {
201
  toggle: (cls, force) => {
202
  if (force) classList.add(cls);
 
214
  expect(classList.has('is-collapsed')).toBe(false);
215
  expect(tab.textContent).toBe('▶');
216
  });
217
+
218
+ it('setVisualizationPanelLayout switches sheet glyphs', () => {
219
+ const classList = new Set(['section-card', 'viz-panel', 'is-collapsed']);
220
+ const tab = {
221
+ textContent: '◀',
222
+ attrs: {},
223
+ setAttribute(k, v) { this.attrs[k] = String(v); },
224
+ };
225
+ const el = {
226
+ dataset: { vizLayout: 'edge' },
227
+ classList: {
228
+ toggle: (cls, force) => {
229
+ if (force) classList.add(cls);
230
+ else classList.delete(cls);
231
+ },
232
+ contains: (cls) => classList.has(cls),
233
+ },
234
+ querySelector: (sel) => (sel === '.viz-panel-tab' ? tab : null),
235
+ };
236
+ setVisualizationPanelLayout(el, 'sheet');
237
+ expect(el.dataset.vizLayout).toBe('sheet');
238
+ expect(tab.textContent).toBe('▲');
239
+ });
240
  });
241
 
242
  describe('shouldShowActivation / filter helpers', () => {