diff --git a/engraphis/classic_assets/dashboard.js b/engraphis/classic_assets/dashboard.js
index 93ec0f83..34f33713 100644
--- a/engraphis/classic_assets/dashboard.js
+++ b/engraphis/classic_assets/dashboard.js
@@ -516,7 +516,7 @@ function licStateBanner(state,plan,ends,status){
if(state==='lapsed'){const note=LIC_STATUS_NOTE[status];return `
Your ${esc(plan||'hosted')} subscription is no longer active${note?esc(note.charAt(0).toUpperCase()+note.slice(1))+', so hosted':'Hosted'} features are locked until billing is up to date. Your local memories are unaffected. Open the account portal to restore access.
`}
if(state==='inactive')return `No hosted plan on this installationThe local memory engine is free and complete on its own. Cloud Sync, Analytics, Automation, and Team administration run in Engraphis Cloud.
`;
return ''}
-function licActionsHtml(state){const pro=hostedCta('pro','license');if(state==='active'||state==='lapsed')return `${ctaLinkHtml(pro,'btn btn-primary btn-sm','license')}
`;const team=hostedCta('team','license_team');return `${ctaLinkHtml(pro,'btn btn-primary btn-sm','license')}${ctaLinkHtml(team,'btn btn-ghost btn-sm','license_team')}
`}
+function licActionsHtml(state){if(state!=='active'&&state!=='lapsed'&&state!=='trial'&&state!=='inactive'&&state!=='trial_expired')return '';const pro=hostedCta('pro','license');return `${ctaLinkHtml(pro,'btn btn-primary btn-sm','license')}
`}
function renderLicense(d){
const el=document.getElementById('lic-body');if(!el)return;
const state=licAccessState(),raw=String(d.plan||'local').toLowerCase();
@@ -537,8 +537,6 @@ function renderLicense(d){
so "the dashboard says PRO" and "the cloud says PRO" could not be told apart. */
if(d.plan_source)h+=`Plan source${esc(LIC_SOURCE_LABEL[d.plan_source]||d.plan_source)}${d.plan_checked_at?' · confirmed '+esc(fmtRel(d.plan_checked_at)):''}
`;
if(state==='active')h+=`Thank you for supporting Engraphis. Your subscription helps fund hosted infrastructure and ongoing development.
`;
- else if(state!=='lapsed')h+=`Support continued Engraphis development with Pro. Your subscription helps cover hosted infrastructure and ongoing development while unlocking Cloud Sync, Analytics, Auto Consolidation, and Auto Dreaming.
`;
- h+=`The local core remains free. Pro and Team capabilities execute in Engraphis Cloud. The email-confirmed, no-card trial lasts exactly ${TRIAL_DAYS} active days; private-service account grace is separate, capped at 24 hours, and never extends cloud access or restricts local MCP and dashboard use.
`;
h+=licActionsHtml(state);
el.innerHTML=h;
}
@@ -584,6 +582,9 @@ const GRAPH_PRESETS={
custom:{label:'Custom tuning',curve:.1,particles:0}
};
window.GSET=window.GSET||{mode:'communities',font:12,size:3,repel:48,link:16,gravity:48,labels:false,linkw:.72,labelDensity:24,flow:true,frozen:false};
+// Freeze is session-only. Never let a pre-existing dashboard state make a newly opened graph
+// look broken: every Classic graph begins with physics live until its visible switch is clicked.
+window.GSET.frozen=false;
/* Keep legacy Classic geometry in the same compact world-space range as Ledger. The old
`size * sqrt(1 + degree)` rule let a highly connected entity become a giant disc, then
zoom-to-fit magnified that disc again. Degree still adds a restrained emphasis, but it is
@@ -735,7 +736,7 @@ function graphRenderEngine(data,fit,reheat){
engine.setLayers(layers);
engine.setScope({showUnlinked,minDegree:showUnlinked?0:1});
if(dataChanged)engine.setData(data);
- },fit,reheat&&!prefersReducedMotion());
+ },fit,reheat);
/* Mirror the engine's clustering back onto the dashboard's own node objects, or the
cluster legend (which reads GACTIVE_DATA) reports one community for the whole store. */
const communityMap=GRAPH_ENGINE.communityMap();
@@ -748,7 +749,7 @@ function graphRenderEngine(data,fit,reheat){
null. Re-apply the parked state here so a renderer created against a hidden pane never
starts a rAF that nothing will stop. */
if(GRAPH_ENGINE_PARKED)GRAPH_ENGINE.pause();
- graphSetSimulationStatus(prefersReducedMotion()?'Static layout':'Adaptive layout',false);
+ graphSetSimulationStatus(window.GSET.frozen?'Layout frozen':'Adaptive layout',false);
return true;
}catch(error){
graphEngineFallback(error);
@@ -1226,19 +1227,19 @@ function graphRender(fit=true,reheat=true){
}
showAs(empty,false);window.GCOL=graphReadThemeColors();graphApplyStyleChrome();graphUpdateHud(data);
const reduced=prefersReducedMotion(),reheatButton=document.querySelector('[data-onclick="h27"]');
- if(reheatButton){reheatButton.setAttribute('aria-disabled',String(reduced));reheatButton.setAttribute('aria-label',reduced?'Reheat layout unavailable while reduced motion is enabled':'Reheat layout');reheatButton.title=reduced?'Unavailable while reduced motion is enabled':''}
+ if(reheatButton){reheatButton.setAttribute('aria-disabled','false');reheatButton.setAttribute('aria-label','Reheat layout');reheatButton.title=''}
if(!FG){
FG=ForceGraph()(element);
FG.backgroundColor('rgba(0,0,0,0)').nodeRelSize(1).autoPauseRedraw(true)
.onRenderFramePre((ctx,scale)=>{try{graphStyleBackground(ctx,scale)}catch(e){}})
.onNodeClick(node=>{syncGraphExplorerSelection(node.id);graphNodeClick(node.label||node.id)})
.onNodeHover(node=>{graphSetHighlight(node&&node.id);element.classList.toggle('cursor-pointer',!!node);element.classList.toggle('cursor-grab',!node)})
- .onEngineStop(()=>graphSetSimulationStatus(prefersReducedMotion()?'Static layout':'Layout settled',false));
+ .onEngineStop(()=>graphSetSimulationStatus('Layout settled',false));
}
FG.width(element.clientWidth).height(element.clientHeight)
- .cooldownTime(reduced?0:(GPERF.large?1100:2200))
- .cooldownTicks(reduced?1:(GPERF.large?80:160))
- .warmupTicks(reduced?45:(GPERF.large?18:40))
+ .cooldownTime(GPERF.large?1100:2200)
+ .cooldownTicks(GPERF.large?80:160)
+ .warmupTicks(GPERF.large?18:40)
.autoPauseRedraw(true);
if(FG.d3AlphaDecay)FG.d3AlphaDecay(GPERF.large?.055:.035);
if(FG.d3VelocityDecay)FG.d3VelocityDecay(GPERF.large?.45:.38);
@@ -1299,7 +1300,7 @@ function graphRender(fit=true,reheat=true){
});
if(dataChanged)FG.graphData(data);
graphApplyForces();
- if(reheat){graphSetSimulationStatus(reduced?'Static layout':'Arranging entities',!reduced);if(!reduced)FG.d3ReheatSimulation()}
+ if(reheat){graphSetSimulationStatus('Arranging entities',true);FG.d3ReheatSimulation()}
else graphRedraw();
clearTimeout(window.__gfit);
if(fit){
@@ -1319,7 +1320,7 @@ function graphSet(key,value){
if(key==='link'&&GACTIVE_DATA)graphRefreshComponentCenters(GACTIVE_DATA.nodes);
if(layout)graphApplyForces();
if(key==='linkw'){FG.linkWidth(FG.linkWidth());FG.linkColor(FG.linkColor())}else graphRedraw();
- if(layout&&!prefersReducedMotion()){graphSetSimulationStatus('Updating layout',true);FG.d3ReheatSimulation()}
+ if(layout){graphSetSimulationStatus('Updating layout',true);FG.d3ReheatSimulation()}
}
function graphApplyPreset(name){
const preset=GRAPH_PRESETS[name]||GRAPH_PRESETS.compact;
@@ -1369,7 +1370,7 @@ function graphToggleFreeze(control){
window.GSET.frozen=control.checked;if(GRAPH_ENGINE){GRAPH_ENGINE.freeze(control.checked);return}if(!FG)return;
const ns=(FG.graphData().nodes)||[];
if(control.checked){ns.forEach(n=>{n.fx=n.x;n.fy=n.y});graphSetSimulationStatus('Layout frozen')}
- else{ns.forEach(n=>{n.fx=null;n.fy=null});if(!prefersReducedMotion())FG.d3ReheatSimulation()}
+ else{ns.forEach(n=>{n.fx=null;n.fy=null});FG.d3ReheatSimulation()}
}
function graphToggleLabels(control){window.GSET.labels=control.checked;if(GRAPH_ENGINE)GRAPH_ENGINE.setSettings({labels:control.checked});else if(FG)graphRender(false,false)}
function graphRecolor(){
@@ -1382,9 +1383,8 @@ function graphRecolor(){
}
function graphFit(){if(GRAPH_ENGINE)GRAPH_ENGINE.fit();else if(FG)FG.zoomToFit(prefersReducedMotion()?0:500,72)}
function graphReheat(){
- if(GRAPH_ENGINE){if(prefersReducedMotion()){toast('Layout motion is off because reduced motion is enabled.','ok');return}GRAPH_ENGINE.reheat();return}
+ if(GRAPH_ENGINE){GRAPH_ENGINE.reheat();return}
if(!FG)return;
- if(prefersReducedMotion()){toast('Layout motion is off because reduced motion is enabled.','ok');return}
graphSetSimulationStatus('Reheating layout',true);FG.d3ReheatSimulation();
}
function graphFocus(name){
diff --git a/engraphis/dashboard_assets/engraphis-graph.js b/engraphis/dashboard_assets/engraphis-graph.js
index cabe0bf3..77283fbf 100644
--- a/engraphis/dashboard_assets/engraphis-graph.js
+++ b/engraphis/dashboard_assets/engraphis-graph.js
@@ -912,8 +912,9 @@
});
}
- /* The dashboard already honours `prefers-reduced-motion` for the classic renderer; this
- engine must not quietly reintroduce perpetual motion for the same user. */
+ /* Reduced motion still controls cosmetic animation and camera transitions. Physics is
+ deliberately controlled by the visible Freeze switch instead: otherwise the switch can
+ say "off" while an OS preference silently leaves every graph static. */
function reduced() {
if (typeof opts.reducedMotion === 'function') return !!opts.reducedMotion();
try {
@@ -1381,13 +1382,24 @@
Shared so reheat() and freeze() cannot drift back to the small-graph constant. */
function alphaDecay() { return large ? 0.055 : 0.035; }
+ // Rendering while frozen deliberately gives force-graph a one-tick budget. Keep the
+ // matching live values in one place so unfreezing after a style, scope, or data render
+ // cannot reheat against that stale one-tick budget.
+ function setSimulationBudget(live) {
+ const simulate = live && !staticFullLayout;
+ if (fg.cooldownTime) fg.cooldownTime(simulate ? (large ? 1100 : 2200) : 0);
+ if (fg.cooldownTicks) fg.cooldownTicks(simulate ? (large ? 80 : 160) : 1);
+ if (fg.warmupTicks) fg.warmupTicks(simulate ? (large ? 18 : 40) : 0);
+ }
+
function render(fit, reheat) {
if (destroyed) return;
if (suspended) {
pendingRender = pendingRender ? [pendingRender[0] || fit, pendingRender[1] || reheat] : [fit, reheat];
return;
}
- const motion = !reduced();
+ const motion = !state.settings.frozen;
+ const reducedMotion = reduced();
const next = visible();
/* Reuse the arrays force-graph already holds when the view is unchanged: the sizing and
colouring pass below must write onto the objects the vendor is painting from, and the
@@ -1432,9 +1444,7 @@
/* Bound the simulation the way the classic path does. Without these force-graph keeps its
15-second default window, so every load and every reheat of a large store runs the
layout — and repaints every node and link — for more than ten seconds longer. */
- if (fg.cooldownTime) fg.cooldownTime(motion && !staticFullLayout ? (large ? 1100 : 2200) : 0);
- if (fg.cooldownTicks) fg.cooldownTicks(motion && !staticFullLayout ? (large ? 80 : 160) : 1);
- if (fg.warmupTicks) fg.warmupTicks(motion && !staticFullLayout ? (large ? 18 : 40) : 0);
+ setSimulationBudget(motion);
if (fg.d3AlphaDecay) fg.d3AlphaDecay(staticFullLayout ? 1 : alphaDecay());
if (fg.d3VelocityDecay) fg.d3VelocityDecay(large ? 0.45 : 0.38);
if (fg.linkCurvature) {
@@ -1446,6 +1456,7 @@
const flowing = !fullGraph
&& state.settings.flow !== false
&& motion
+ && !reducedMotion
&& data.links.length <= PARTICLE_LINK_LIMIT;
const particles = !flowing
? 0
@@ -1462,8 +1473,9 @@
but Style, Color by and Labels all just changed how the *same* data must be drawn. */
if (reused) invalidate();
if (fit) {
+ const animateFit = motion && !reducedMotion;
clearTimeout(fitTimer);
- fitTimer = setTimeout(() => { if (!destroyed) autoFit(motion ? 600 : 0, 40); }, motion ? 320 : 0);
+ fitTimer = setTimeout(() => { if (!destroyed) autoFit(animateFit ? 600 : 0, 40); }, animateFit ? 320 : 0);
}
if (opts.onStats) opts.onStats({ nodes: data.nodes.length, links: data.links.length, total: raw.nodes.length, totalLinks: raw.links.length, preset: (PRESETS[state.settings.mode] || PRESETS.compact).label, collapsed: collapsed, ghosts: data.nodes.filter(n => n.ghost).length, bridges: data.links.filter(l => l.bridge).length, suggested: data.links.filter(l => l.suggested).length });
}
@@ -1781,7 +1793,7 @@
};
api.fit = () => { if (!destroyed) fg.zoomToFit(reduced() ? 0 : 500, 40); };
api.reheat = () => {
- if (destroyed || reduced() || staticFullLayout) return;
+ if (destroyed || state.settings.frozen || staticFullLayout) return;
raw.nodes.forEach(n => { n.fx = undefined; n.fy = undefined; });
if (fg.d3ReheatSimulation) { fg.d3AlphaDecay(alphaDecay()); fg.d3ReheatSimulation(); }
};
@@ -1798,7 +1810,10 @@
if (staticFullLayout) return;
raw.nodes.forEach(n => { n.fx = undefined; n.fy = undefined; });
applyForces();
- if (reduced()) return;
+ setSimulationBudget(true);
+ // A frozen render removes relation-flow particles. Reapply the live paint settings
+ // before reheating so the enabled flow switch immediately becomes visible again.
+ render(false, false);
fg.d3AlphaDecay(alphaDecay());
if (fg.d3ReheatSimulation) fg.d3ReheatSimulation();
};
diff --git a/engraphis/dashboard_assets/ledger.js b/engraphis/dashboard_assets/ledger.js
index 91bf2f4b..b92a482b 100644
--- a/engraphis/dashboard_assets/ledger.js
+++ b/engraphis/dashboard_assets/ledger.js
@@ -1596,7 +1596,6 @@
palette: byId('graph-palette').value,
flow: byId('graph-flow').getAttribute('aria-checked') === 'true',
labels: byId('graph-labels').getAttribute('aria-checked') === 'true',
- frozen: state.graphFrozen,
tuning: graphTuningSettings(),
minDegree: number(byId('graph-min-degree').value),
depth: number(byId('graph-depth').value),
@@ -1654,7 +1653,9 @@
byId('graph-ghosts').checked = graphPreference('ghosts', byId('graph-ghosts').checked) !== false;
byId('graph-size').value = graphPreference('size', byId('graph-size').value,
['degree', 'betweenness']);
- state.graphFrozen = graphPreference('frozen', false) === true;
+ // Freeze is deliberately session-only. A previously frozen arrangement must not make a
+ // freshly opened graph look broken; physics starts live until the person clicks Freeze.
+ state.graphFrozen = false;
setGraphSwitch('graph-freeze', state.graphFrozen);
setGraphSwitch('graph-flow', graphPreference('flow', true) !== false);
setGraphSwitch('graph-labels', graphPreference('labels', false) === true);
@@ -1700,7 +1701,6 @@
? view.repoFilter.slice(0, 200) : byId('graph-repo-filter').value;
state.graphIncludeCode = typeof view.includeCode === 'boolean'
? view.includeCode : state.graphIncludeCode;
- state.graphFrozen = typeof view.frozen === 'boolean' ? view.frozen : state.graphFrozen;
byId('graph-preset').value = preset;
byId('graph-style').value = style;
byId('graph-color').value = color;
diff --git a/engraphis/static/dashboard.js b/engraphis/static/dashboard.js
index 93ec0f83..34f33713 100644
--- a/engraphis/static/dashboard.js
+++ b/engraphis/static/dashboard.js
@@ -516,7 +516,7 @@ function licStateBanner(state,plan,ends,status){
if(state==='lapsed'){const note=LIC_STATUS_NOTE[status];return `Your ${esc(plan||'hosted')} subscription is no longer active${note?esc(note.charAt(0).toUpperCase()+note.slice(1))+', so hosted':'Hosted'} features are locked until billing is up to date. Your local memories are unaffected. Open the account portal to restore access.
`}
if(state==='inactive')return `No hosted plan on this installationThe local memory engine is free and complete on its own. Cloud Sync, Analytics, Automation, and Team administration run in Engraphis Cloud.
`;
return ''}
-function licActionsHtml(state){const pro=hostedCta('pro','license');if(state==='active'||state==='lapsed')return `${ctaLinkHtml(pro,'btn btn-primary btn-sm','license')}
`;const team=hostedCta('team','license_team');return `${ctaLinkHtml(pro,'btn btn-primary btn-sm','license')}${ctaLinkHtml(team,'btn btn-ghost btn-sm','license_team')}
`}
+function licActionsHtml(state){if(state!=='active'&&state!=='lapsed'&&state!=='trial'&&state!=='inactive'&&state!=='trial_expired')return '';const pro=hostedCta('pro','license');return `${ctaLinkHtml(pro,'btn btn-primary btn-sm','license')}
`}
function renderLicense(d){
const el=document.getElementById('lic-body');if(!el)return;
const state=licAccessState(),raw=String(d.plan||'local').toLowerCase();
@@ -537,8 +537,6 @@ function renderLicense(d){
so "the dashboard says PRO" and "the cloud says PRO" could not be told apart. */
if(d.plan_source)h+=`Plan source${esc(LIC_SOURCE_LABEL[d.plan_source]||d.plan_source)}${d.plan_checked_at?' · confirmed '+esc(fmtRel(d.plan_checked_at)):''}
`;
if(state==='active')h+=`Thank you for supporting Engraphis. Your subscription helps fund hosted infrastructure and ongoing development.
`;
- else if(state!=='lapsed')h+=`Support continued Engraphis development with Pro. Your subscription helps cover hosted infrastructure and ongoing development while unlocking Cloud Sync, Analytics, Auto Consolidation, and Auto Dreaming.
`;
- h+=`The local core remains free. Pro and Team capabilities execute in Engraphis Cloud. The email-confirmed, no-card trial lasts exactly ${TRIAL_DAYS} active days; private-service account grace is separate, capped at 24 hours, and never extends cloud access or restricts local MCP and dashboard use.
`;
h+=licActionsHtml(state);
el.innerHTML=h;
}
@@ -584,6 +582,9 @@ const GRAPH_PRESETS={
custom:{label:'Custom tuning',curve:.1,particles:0}
};
window.GSET=window.GSET||{mode:'communities',font:12,size:3,repel:48,link:16,gravity:48,labels:false,linkw:.72,labelDensity:24,flow:true,frozen:false};
+// Freeze is session-only. Never let a pre-existing dashboard state make a newly opened graph
+// look broken: every Classic graph begins with physics live until its visible switch is clicked.
+window.GSET.frozen=false;
/* Keep legacy Classic geometry in the same compact world-space range as Ledger. The old
`size * sqrt(1 + degree)` rule let a highly connected entity become a giant disc, then
zoom-to-fit magnified that disc again. Degree still adds a restrained emphasis, but it is
@@ -735,7 +736,7 @@ function graphRenderEngine(data,fit,reheat){
engine.setLayers(layers);
engine.setScope({showUnlinked,minDegree:showUnlinked?0:1});
if(dataChanged)engine.setData(data);
- },fit,reheat&&!prefersReducedMotion());
+ },fit,reheat);
/* Mirror the engine's clustering back onto the dashboard's own node objects, or the
cluster legend (which reads GACTIVE_DATA) reports one community for the whole store. */
const communityMap=GRAPH_ENGINE.communityMap();
@@ -748,7 +749,7 @@ function graphRenderEngine(data,fit,reheat){
null. Re-apply the parked state here so a renderer created against a hidden pane never
starts a rAF that nothing will stop. */
if(GRAPH_ENGINE_PARKED)GRAPH_ENGINE.pause();
- graphSetSimulationStatus(prefersReducedMotion()?'Static layout':'Adaptive layout',false);
+ graphSetSimulationStatus(window.GSET.frozen?'Layout frozen':'Adaptive layout',false);
return true;
}catch(error){
graphEngineFallback(error);
@@ -1226,19 +1227,19 @@ function graphRender(fit=true,reheat=true){
}
showAs(empty,false);window.GCOL=graphReadThemeColors();graphApplyStyleChrome();graphUpdateHud(data);
const reduced=prefersReducedMotion(),reheatButton=document.querySelector('[data-onclick="h27"]');
- if(reheatButton){reheatButton.setAttribute('aria-disabled',String(reduced));reheatButton.setAttribute('aria-label',reduced?'Reheat layout unavailable while reduced motion is enabled':'Reheat layout');reheatButton.title=reduced?'Unavailable while reduced motion is enabled':''}
+ if(reheatButton){reheatButton.setAttribute('aria-disabled','false');reheatButton.setAttribute('aria-label','Reheat layout');reheatButton.title=''}
if(!FG){
FG=ForceGraph()(element);
FG.backgroundColor('rgba(0,0,0,0)').nodeRelSize(1).autoPauseRedraw(true)
.onRenderFramePre((ctx,scale)=>{try{graphStyleBackground(ctx,scale)}catch(e){}})
.onNodeClick(node=>{syncGraphExplorerSelection(node.id);graphNodeClick(node.label||node.id)})
.onNodeHover(node=>{graphSetHighlight(node&&node.id);element.classList.toggle('cursor-pointer',!!node);element.classList.toggle('cursor-grab',!node)})
- .onEngineStop(()=>graphSetSimulationStatus(prefersReducedMotion()?'Static layout':'Layout settled',false));
+ .onEngineStop(()=>graphSetSimulationStatus('Layout settled',false));
}
FG.width(element.clientWidth).height(element.clientHeight)
- .cooldownTime(reduced?0:(GPERF.large?1100:2200))
- .cooldownTicks(reduced?1:(GPERF.large?80:160))
- .warmupTicks(reduced?45:(GPERF.large?18:40))
+ .cooldownTime(GPERF.large?1100:2200)
+ .cooldownTicks(GPERF.large?80:160)
+ .warmupTicks(GPERF.large?18:40)
.autoPauseRedraw(true);
if(FG.d3AlphaDecay)FG.d3AlphaDecay(GPERF.large?.055:.035);
if(FG.d3VelocityDecay)FG.d3VelocityDecay(GPERF.large?.45:.38);
@@ -1299,7 +1300,7 @@ function graphRender(fit=true,reheat=true){
});
if(dataChanged)FG.graphData(data);
graphApplyForces();
- if(reheat){graphSetSimulationStatus(reduced?'Static layout':'Arranging entities',!reduced);if(!reduced)FG.d3ReheatSimulation()}
+ if(reheat){graphSetSimulationStatus('Arranging entities',true);FG.d3ReheatSimulation()}
else graphRedraw();
clearTimeout(window.__gfit);
if(fit){
@@ -1319,7 +1320,7 @@ function graphSet(key,value){
if(key==='link'&&GACTIVE_DATA)graphRefreshComponentCenters(GACTIVE_DATA.nodes);
if(layout)graphApplyForces();
if(key==='linkw'){FG.linkWidth(FG.linkWidth());FG.linkColor(FG.linkColor())}else graphRedraw();
- if(layout&&!prefersReducedMotion()){graphSetSimulationStatus('Updating layout',true);FG.d3ReheatSimulation()}
+ if(layout){graphSetSimulationStatus('Updating layout',true);FG.d3ReheatSimulation()}
}
function graphApplyPreset(name){
const preset=GRAPH_PRESETS[name]||GRAPH_PRESETS.compact;
@@ -1369,7 +1370,7 @@ function graphToggleFreeze(control){
window.GSET.frozen=control.checked;if(GRAPH_ENGINE){GRAPH_ENGINE.freeze(control.checked);return}if(!FG)return;
const ns=(FG.graphData().nodes)||[];
if(control.checked){ns.forEach(n=>{n.fx=n.x;n.fy=n.y});graphSetSimulationStatus('Layout frozen')}
- else{ns.forEach(n=>{n.fx=null;n.fy=null});if(!prefersReducedMotion())FG.d3ReheatSimulation()}
+ else{ns.forEach(n=>{n.fx=null;n.fy=null});FG.d3ReheatSimulation()}
}
function graphToggleLabels(control){window.GSET.labels=control.checked;if(GRAPH_ENGINE)GRAPH_ENGINE.setSettings({labels:control.checked});else if(FG)graphRender(false,false)}
function graphRecolor(){
@@ -1382,9 +1383,8 @@ function graphRecolor(){
}
function graphFit(){if(GRAPH_ENGINE)GRAPH_ENGINE.fit();else if(FG)FG.zoomToFit(prefersReducedMotion()?0:500,72)}
function graphReheat(){
- if(GRAPH_ENGINE){if(prefersReducedMotion()){toast('Layout motion is off because reduced motion is enabled.','ok');return}GRAPH_ENGINE.reheat();return}
+ if(GRAPH_ENGINE){GRAPH_ENGINE.reheat();return}
if(!FG)return;
- if(prefersReducedMotion()){toast('Layout motion is off because reduced motion is enabled.','ok');return}
graphSetSimulationStatus('Reheating layout',true);FG.d3ReheatSimulation();
}
function graphFocus(name){
diff --git a/tests/e2e/commercial.spec.js b/tests/e2e/commercial.spec.js
index 989ec887..a19ba145 100644
--- a/tests/e2e/commercial.spec.js
+++ b/tests/e2e/commercial.spec.js
@@ -267,7 +267,7 @@ async function openView(page, name) {
await expect(page.locator(`#view-${name}`)).toHaveClass(/\bactive\b/);
}
-test('local dashboard exposes hosted Pro and Team CTAs without local commercial controls', async ({ page }) => {
+test('local dashboard keeps generic Pro and Team CTAs out of settings', async ({ page }) => {
const errors = recordBrowserErrors(page);
const calls = await mockLocalClient(page);
const response = await page.goto('/classic');
@@ -281,11 +281,8 @@ test('local dashboard exposes hosted Pro and Team CTAs without local commercial
const licensePanel = page.locator('.settings-license-panel');
await expect(licensePanel.getByText('LOCAL CORE', { exact: true })).toBeVisible();
await expect(licensePanel.getByRole('link', { name: 'Start 3-day Pro trial' })).toBeVisible();
- await expect(licensePanel.getByRole('link', { name: 'Start 3-day Team trial' })).toBeVisible();
- await expect(licensePanel).toContainText(
- 'The email-confirmed, no-card trial lasts exactly 3 active days; '
- + 'private-service account grace is separate, capped at 24 hours, and never extends cloud access or restricts local MCP and dashboard use.',
- );
+ await expect(licensePanel.getByRole('link', { name: 'Start 3-day Team trial' })).toHaveCount(0);
+ await expect(licensePanel).not.toContainText('Support continued Engraphis development with Pro.');
await openView(page, 'team');
const team = page.locator('#team-body');
@@ -467,17 +464,9 @@ test('a spent trial says so, and is never offered another one', async ({ page })
await expect(licensePanel).toContainText('Your free trial has ended on 2025-06-28');
await expect(licensePanel).toContainText('still in your local database');
await expect(licensePanel).toContainText('cannot be started again');
- // Buyable, not trialable.
- await expect(licensePanel.getByRole('link', { name: 'Subscribe to Pro' }))
- .toHaveAttribute(
- 'href',
- 'https://cloud.engraphis.test/pro?plan=pro&interval=monthly&utm_source=engraphis&utm_medium=product&utm_campaign=pro_conversion&utm_content=license#billing',
- );
- await expect(licensePanel.getByRole('link', { name: 'Subscribe to Team' }))
- .toHaveAttribute(
- 'href',
- 'https://cloud.engraphis.test/team?plan=team&interval=monthly&utm_source=engraphis&utm_medium=product&utm_campaign=pro_conversion&utm_content=license_team#billing',
- );
+ // The header GET PRO badge opens this panel, so it must retain the matching checkout action.
+ await expect(licensePanel.getByRole('link', { name: 'Subscribe to Pro' })).toBeVisible();
+ await expect(licensePanel.getByRole('link', { name: 'Subscribe to Team' })).toHaveCount(0);
await expect(licensePanel.getByRole('link', { name: 'Start 3-day Pro trial' }))
.toHaveCount(0);
expect(errors).toEqual([]);
diff --git a/tests/e2e/graph-engine.spec.js b/tests/e2e/graph-engine.spec.js
index c22b125b..8ab988dd 100644
--- a/tests/e2e/graph-engine.spec.js
+++ b/tests/e2e/graph-engine.spec.js
@@ -188,6 +188,45 @@ test('Ledger bounds auto-fit zoom and keeps a dragged node under the pointer', a
expect(session.pageErrors).toEqual([]);
});
+test('Classic keeps a dragged node under the pointer with reduced visual motion', async ({ page }) => {
+ await page.emulateMedia({ reducedMotion: 'reduce' });
+ const session = await openDashboard(page, { query: '?graph-engine=next' });
+ await openGraphView(page);
+ await page.waitForFunction(() => window.__fg && window.__fg.graphData().nodes.length > 0);
+ await page.waitForFunction(() => window.__fg.graphData().nodes
+ .every(node => Number.isFinite(node.x) && Number.isFinite(node.y)));
+ await page.waitForTimeout(500);
+
+ const drag = await page.evaluate(() => {
+ const graph = window.__fg;
+ const node = graph.graphData().nodes[0];
+ const canvas = document.querySelector('#graph-net canvas');
+ const box = canvas.getBoundingClientRect();
+ const point = graph.graph2ScreenCoords(node.x, node.y);
+ return {
+ before: { x: node.x, y: node.y },
+ zoom: canvas.__zoom.k,
+ x: box.left + point.x,
+ y: box.top + point.y,
+ };
+ });
+
+ await page.mouse.move(drag.x, drag.y);
+ await page.mouse.down();
+ await page.mouse.move(drag.x + 80, drag.y + 40, { steps: 8 });
+ await page.mouse.up();
+ const after = await page.evaluate(() => {
+ const node = window.__fg.graphData().nodes[0];
+ return { x: node.x, y: node.y, fx: node.fx, fy: node.fy };
+ });
+
+ expect(after.x - drag.before.x).toBeCloseTo(80 / drag.zoom, 0);
+ expect(after.y - drag.before.y).toBeCloseTo(40 / drag.zoom, 0);
+ expect(after.fx).toBeCloseTo(after.x, 8);
+ expect(after.fy).toBeCloseTo(after.y, 8);
+ expect(session.pageErrors).toEqual([]);
+});
+
test('a dashboard page that never opens the graph fetches neither graph script', async ({ page }) => {
// The reason both scripts are lazy is not weight, it is CSP: force-graph applies inline
// styles at runtime, so an eager