Spaces:
Sleeping
Sleeping
fix(ui): compact mobile chrome and Visualization bottom sheet
Browse filesShrink 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 +2 -0
- src/main.js +50 -6
- src/style.css +118 -41
- src/ui/TouchControls.js +1 -0
- src/ui/VisualizationControls.js +64 -10
- tests/TouchControls.test.js +10 -0
- tests/visualizationControls.test.js +58 -0
.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
|
|
|
|
| 217 |
*/
|
| 218 |
mountVisualizationControlsUI() {
|
| 219 |
this.vizConfig = loadVisualizationSettings();
|
| 220 |
const storage = typeof localStorage !== 'undefined' ? localStorage : null;
|
|
|
|
|
|
|
| 221 |
const collapsed = readVisualizationPanelCollapsed(storage, {
|
| 222 |
-
isMobile:
|
| 223 |
});
|
| 224 |
const wrapper = document.createElement('div');
|
| 225 |
-
wrapper.innerHTML = visualizationControlsMarkup(this.vizConfig, { collapsed });
|
| 226 |
const vizEl = wrapper.firstElementChild;
|
| 227 |
this.vizEl = vizEl;
|
| 228 |
-
|
| 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:
|
| 1558 |
flex-wrap: wrap;
|
| 1559 |
-
gap:
|
| 1560 |
-
padding:
|
| 1561 |
-
align-items:
|
| 1562 |
}
|
| 1563 |
|
| 1564 |
.navbar-brand .version-tag,
|
|
@@ -1567,12 +1568,13 @@ canvas {
|
|
| 1567 |
}
|
| 1568 |
|
| 1569 |
.title-group h1 {
|
| 1570 |
-
font-size: 0.
|
|
|
|
| 1571 |
}
|
| 1572 |
|
| 1573 |
.navbar-center-controls {
|
| 1574 |
flex-wrap: wrap;
|
| 1575 |
-
gap:
|
| 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:
|
| 1585 |
-
padding:
|
| 1586 |
}
|
| 1587 |
|
| 1588 |
.tab-label {
|
|
@@ -1592,10 +1594,11 @@ canvas {
|
|
| 1592 |
.mode-tab,
|
| 1593 |
.view-tab,
|
| 1594 |
.workspace-tab {
|
| 1595 |
-
min-height:
|
| 1596 |
-
min-width:
|
| 1597 |
-
padding:
|
| 1598 |
-
font-size: 0.
|
|
|
|
| 1599 |
}
|
| 1600 |
|
| 1601 |
.status-indicator {
|
|
@@ -1603,8 +1606,8 @@ canvas {
|
|
| 1603 |
}
|
| 1604 |
|
| 1605 |
.status-text {
|
| 1606 |
-
font-size: 0.
|
| 1607 |
-
max-width:
|
| 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:
|
| 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(
|
| 1626 |
}
|
| 1627 |
|
| 1628 |
.collapsible-dock:not(.is-collapsed) .dock-body {
|
|
@@ -1630,14 +1633,14 @@ canvas {
|
|
| 1630 |
}
|
| 1631 |
|
| 1632 |
.dock-tab {
|
| 1633 |
-
min-height:
|
| 1634 |
-
min-width:
|
| 1635 |
-
width:
|
| 1636 |
-
flex-basis:
|
| 1637 |
}
|
| 1638 |
|
| 1639 |
.collapsible-dock {
|
| 1640 |
-
--dock-tab-size:
|
| 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:
|
| 1696 |
-
|
|
|
|
|
|
|
|
|
|
| 1697 |
gap: 10px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1698 |
}
|
| 1699 |
|
| 1700 |
.slider-group input[type="range"] {
|
| 1701 |
min-height: 44px;
|
| 1702 |
}
|
| 1703 |
|
|
|
|
| 1704 |
.glass-hud {
|
| 1705 |
-
left: max(
|
| 1706 |
-
right: max(
|
| 1707 |
-
bottom: max(
|
| 1708 |
height: auto;
|
| 1709 |
-
min-height:
|
| 1710 |
-
padding:
|
| 1711 |
-
font-size: 0.
|
| 1712 |
-
flex-wrap:
|
| 1713 |
-
gap:
|
|
|
|
| 1714 |
}
|
| 1715 |
|
| 1716 |
-
.hud-left, .hud-right {
|
| 1717 |
-
flex:
|
|
|
|
| 1718 |
}
|
| 1719 |
|
| 1720 |
.hud-center {
|
| 1721 |
-
flex: 1 1
|
| 1722 |
-
order:
|
| 1723 |
-
justify-content:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1724 |
}
|
| 1725 |
|
| 1726 |
.hud-badge {
|
| 1727 |
-
max-width:
|
|
|
|
|
|
|
| 1728 |
}
|
| 1729 |
|
| 1730 |
.hud-token-text {
|
| 1731 |
-
max-width:
|
| 1732 |
}
|
| 1733 |
|
| 1734 |
.hud-highlight {
|
| 1735 |
-
font-size: 0.
|
| 1736 |
}
|
| 1737 |
|
| 1738 |
#axis-gizmo-container {
|
| 1739 |
-
width:
|
| 1740 |
-
height:
|
| 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
|
| 3 |
-
*
|
|
|
|
| 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', () => {
|