From 4176fcdda64c660eed6e7635eb54357a96316527 Mon Sep 17 00:00:00 2001 From: Jaixii Date: Mon, 3 Aug 2026 02:16:52 -0400 Subject: [PATCH 1/6] fix: restore live dashboard graph physics --- engraphis/classic_assets/dashboard.js | 23 ++++----- engraphis/dashboard_assets/engraphis-graph.js | 12 +++-- engraphis/dashboard_assets/ledger.js | 6 +-- engraphis/static/dashboard.js | 23 ++++----- tests/e2e/commercial.spec.js | 25 +++------- tests/e2e/ledger.spec.js | 9 +++- tests/test_dashboard_auth_placement.py | 12 +++-- tests/test_graph_engine_asset.py | 48 ++++++++++++++----- 8 files changed, 86 insertions(+), 72 deletions(-) diff --git a/engraphis/classic_assets/dashboard.js b/engraphis/classic_assets/dashboard.js index 93ec0f83..85223c1e 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')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; } @@ -1226,19 +1224,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 +1297,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 +1317,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 +1367,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 +1380,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..2cc16d6d 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 { @@ -1387,7 +1388,8 @@ 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 @@ -1446,6 +1448,7 @@ const flowing = !fullGraph && state.settings.flow !== false && motion + && !reducedMotion && data.links.length <= PARTICLE_LINK_LIMIT; const particles = !flowing ? 0 @@ -1781,7 +1784,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 +1801,6 @@ if (staticFullLayout) return; raw.nodes.forEach(n => { n.fx = undefined; n.fy = undefined; }); applyForces(); - if (reduced()) return; 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..85223c1e 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')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; } @@ -1226,19 +1224,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 +1297,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 +1317,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 +1367,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 +1380,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..63cb2b55 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'); @@ -280,12 +280,9 @@ test('local dashboard exposes hosted Pro and Team CTAs without local commercial await openView(page, 'settings'); 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 Pro trial' })).toHaveCount(0); + 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', - ); + // Upgrade CTAs belong with individual locked features, not the general settings panel. + await expect(licensePanel.getByRole('link', { name: 'Subscribe to Pro' })).toHaveCount(0); + 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/ledger.spec.js b/tests/e2e/ledger.spec.js index b6d68d0e..509aa5ba 100644 --- a/tests/e2e/ledger.spec.js +++ b/tests/e2e/ledger.spec.js @@ -459,6 +459,11 @@ test('Relationships uses the visual explorer controls and applies their state', await flow.click(); await expect(flow).toHaveAttribute('aria-checked', 'false'); const freeze = page.getByRole('switch', { name: 'Freeze simulation' }); + await expect(freeze).toHaveAttribute('aria-checked', 'false'); + await freeze.click(); + await expect(freeze).toHaveAttribute('aria-checked', 'true'); + await freeze.click(); + await expect(freeze).toHaveAttribute('aria-checked', 'false'); await freeze.click(); await expect(freeze).toHaveAttribute('aria-checked', 'true'); @@ -502,7 +507,7 @@ test('Relationships uses the visual explorer controls and applies their state', await expect(page.getByRole('button', { name: 'Type' })).toHaveAttribute('aria-pressed', 'true'); await expect(page.locator('#graph-flow-speed')).toHaveValue('45'); await expect(page.getByRole('switch', { name: 'Relation flow' })).toHaveAttribute('aria-checked', 'false'); - await expect(page.getByRole('switch', { name: 'Freeze simulation' })).toHaveAttribute('aria-checked', 'true'); + await expect(page.getByRole('switch', { name: 'Freeze simulation' })).toHaveAttribute('aria-checked', 'false'); }); test('graph node connections expose linked memory evidence without leaving the graph', async ({ page }) => { @@ -599,7 +604,7 @@ test('a custom graph view restores every saved control and server filter', async await expect(page.locator('#graph-repel')).toHaveValue('80'); await expect(page.getByRole('switch', { name: 'Relation flow' })).toHaveAttribute('aria-checked', 'false'); await expect(page.getByRole('switch', { name: 'Entity labels' })).toHaveAttribute('aria-checked', 'true'); - await expect(page.getByRole('switch', { name: 'Freeze simulation' })).toHaveAttribute('aria-checked', 'true'); + await expect(page.getByRole('switch', { name: 'Freeze simulation' })).toHaveAttribute('aria-checked', 'false'); await expect(page.getByLabel('Size by')).toHaveValue('betweenness'); await expect(page.getByLabel('Highlight bridges')).toBeChecked(); await expect(page.getByLabel('Auto-collapse clusters')).toBeChecked(); diff --git a/tests/test_dashboard_auth_placement.py b/tests/test_dashboard_auth_placement.py index 00045a03..49d79a5f 100644 --- a/tests/test_dashboard_auth_placement.py +++ b/tests/test_dashboard_auth_placement.py @@ -540,10 +540,9 @@ def test_a_lapsed_customer_with_no_readable_plan_still_gets_a_billing_target(tmp @pytest.mark.skipif(shutil.which("node") is None, reason="node is required to run the UI") @pytest.mark.parametrize("state,expected,absent", [ - # Only the state a trial can actually be started in draws the trial buttons; the - # control plane refuses one for every organization that already holds an entitlement. - ("inactive", "Start 3-day Pro trial", "Subscribe to Pro"), - ("trial_expired", "Subscribe to Pro", "Start 3-day Pro trial"), + # Upgrade CTAs belong to their respective feature cards, not the general settings panel. + ("inactive", "", "Subscribe to Pro"), + ("trial_expired", "", "Subscribe to Pro"), ("trial", "Open Engraphis Cloud", "Start 3-day Pro trial"), ("active", "Open Engraphis Cloud", "Start 3-day Pro trial"), ]) @@ -557,7 +556,10 @@ def test_each_access_state_offers_the_one_action_that_can_succeed( "available": state == "inactive", "ends_at": 0}}, }])[state]["html"] - assert expected in html + if expected: + assert expected in html + else: + assert html == "" assert absent not in html diff --git a/tests/test_graph_engine_asset.py b/tests/test_graph_engine_asset.py index c0ad4acd..06db98e1 100644 --- a/tests/test_graph_engine_asset.py +++ b/tests/test_graph_engine_asset.py @@ -1294,11 +1294,33 @@ def test_unfreezing_releases_nodes_pinned_by_dragging() -> None: assert report["released"] == {}, "unfreezing left a dragged node immovable" +@requires_node +def test_freeze_is_the_physics_gate_even_with_reduced_motion() -> None: + """The switch must never claim physics is live while an OS preference disables it.""" + + report = _run_engine( + """ + const reheats = () => invocations.d3ReheatSimulation || 0; + const api = G.create(el, { reducedMotion: () => true }); + api.setData(chain(2)); + const started = { time: store.cooldownTime, ticks: store.cooldownTicks, reheats: reheats() }; + api.freeze(true); + const frozen = { alpha: store.d3AlphaDecay, reheats: reheats() }; + api.freeze(false); + emit({ started, frozen, resumed: { alpha: store.d3AlphaDecay, reheats: reheats() } }); + """ + ) + assert report["started"] == {"time": 2200, "ticks": 160, "reheats": 1} + assert report["frozen"]["alpha"] == 1 + assert report["resumed"]["alpha"] == 0.035 + assert report["resumed"]["reheats"] == 2 + + def test_primary_graph_starts_unfrozen_so_the_force_controls_take_effect() -> None: """A fresh graph must settle, rather than make every tuning control look inert.""" assert "graphFrozen: false" in PRIMARY_LEDGER.read_text(encoding="utf-8") - assert "graphPreference('frozen', false)" in PRIMARY_LEDGER.read_text(encoding="utf-8") + assert "state.graphFrozen = false;" in PRIMARY_LEDGER.read_text(encoding="utf-8") assert 'id="graph-freeze" class="graph-switch"' in PRIMARY_INDEX.read_text(encoding="utf-8") freeze_control = PRIMARY_INDEX.read_text(encoding="utf-8").split('id="graph-freeze"', 1)[1] assert 'aria-checked="false"' in freeze_control @@ -1489,11 +1511,12 @@ def test_simulation_time_is_bounded_on_a_large_graph() -> None: time: store.cooldownTime, ticks: store.cooldownTicks, warmup: store.warmupTicks, alpha: store.d3AlphaDecay, velocity: store.d3VelocityDecay, }; - const still = G.create(el, { reducedMotion: () => true }); - still.setData(chain(40)); + const frozen = G.create(el, { reducedMotion: () => true }); + frozen.setData(chain(40)); + frozen.freeze(true); emit({ small, big, - reduced: { time: store.cooldownTime, ticks: store.cooldownTicks }, + frozen: { time: store.cooldownTime, ticks: store.cooldownTicks }, }); """ ) @@ -1506,9 +1529,9 @@ def test_simulation_time_is_bounded_on_a_large_graph() -> None: # A large graph also settles harder, exactly as GPERF.large does on the classic path. assert report["big"]["alpha"] > report["small"]["alpha"] assert report["big"]["velocity"] > report["small"]["velocity"] - # Reduced motion asks for a static layout, not a shorter animation. - assert report["reduced"]["time"] == 0 - assert report["reduced"]["ticks"] == 1 + # Freeze, not the OS visual-motion preference, is the explicit static-layout control. + assert report["frozen"]["time"] == 2200 + assert report["frozen"]["ticks"] == 160 @requires_node @@ -1518,7 +1541,7 @@ def test_physics_sliders_reheat_the_simulation_the_way_the_classic_renderer_does ``graphSet`` (dashboard.js) routes Repel/Link/Gravity/Size/Font/Link-width/Label-density through ``setSettings`` under ``?graph-engine=next``. The classic branch of that same function treats ``repel|link|gravity|size`` as *layout* changes: it re-applies the forces - and then reheats unless the user asked for reduced motion. The engine's ``applyForces()`` + and then reheats unless the user explicitly froze the graph. The engine's ``applyForces()`` only swaps the charge/link/forceX-forceY/collide values into the running simulation — and a settled graph sits at alpha~0 — so without the reheat those four sliders are inert until the user finds the Reheat button. The paint-only settings must *not* reheat: restarting @@ -1546,10 +1569,9 @@ def test_physics_sliders_reheat_the_simulation_the_way_the_classic_renderer_does flow: bump(api, { flow: false }), }; - // The classic path's `if(layout&&!prefersReducedMotion())` exemption. - const still = G.create(el, { reducedMotion: () => true }); - still.setData(chain(40)); - const reducedMotion = bump(still, { repel: 260 }); + const reduced = G.create(el, { reducedMotion: () => true }); + reduced.setData(chain(40)); + const reducedMotion = bump(reduced, { repel: 260 }); emit({ layout, paint, reducedMotion }); """ ) @@ -1561,7 +1583,7 @@ def test_physics_sliders_reheat_the_simulation_the_way_the_classic_renderer_does assert report["paint"] == { "font": 0, "linkw": 0, "labelDensity": 0, "labels": 0, "flow": 0 }, "an appearance change restarted the layout" - assert report["reducedMotion"] == 0, "reduced motion still got an animated relayout" + assert report["reducedMotion"] == 1, "reduced motion silently disabled live physics" @requires_node From 09b40220fce2897de628110d54cd77b43e2c1889 Mon Sep 17 00:00:00 2001 From: Jaixii Date: Mon, 3 Aug 2026 02:22:29 -0400 Subject: [PATCH 2/6] chore: retrigger release checks From 089125652c683d63152910d9dec48e401154ceb2 Mon Sep 17 00:00:00 2001 From: Jaixii Date: Mon, 3 Aug 2026 02:38:50 -0400 Subject: [PATCH 3/6] fix: restore graph physics after unfreeze --- engraphis/classic_assets/dashboard.js | 2 +- engraphis/dashboard_assets/engraphis-graph.js | 15 ++++++++--- engraphis/static/dashboard.js | 2 +- tests/e2e/commercial.spec.js | 6 ++--- tests/test_dashboard_auth_placement.py | 12 ++++----- tests/test_graph_engine_asset.py | 26 +++++++++++++++++++ 6 files changed, 48 insertions(+), 15 deletions(-) diff --git a/engraphis/classic_assets/dashboard.js b/engraphis/classic_assets/dashboard.js index 85223c1e..21bc75d9 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){if(state!=='active'&&state!=='lapsed'&&state!=='trial')return '';const pro=hostedCta('pro','license');return `
${ctaLinkHtml(pro,'btn btn-primary btn-sm','license')}
`} +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(); diff --git a/engraphis/dashboard_assets/engraphis-graph.js b/engraphis/dashboard_assets/engraphis-graph.js index 2cc16d6d..b7aa9298 100644 --- a/engraphis/dashboard_assets/engraphis-graph.js +++ b/engraphis/dashboard_assets/engraphis-graph.js @@ -1382,6 +1382,16 @@ 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) { @@ -1434,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) { @@ -1801,6 +1809,7 @@ if (staticFullLayout) return; raw.nodes.forEach(n => { n.fx = undefined; n.fy = undefined; }); applyForces(); + setSimulationBudget(true); fg.d3AlphaDecay(alphaDecay()); if (fg.d3ReheatSimulation) fg.d3ReheatSimulation(); }; diff --git a/engraphis/static/dashboard.js b/engraphis/static/dashboard.js index 85223c1e..21bc75d9 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){if(state!=='active'&&state!=='lapsed'&&state!=='trial')return '';const pro=hostedCta('pro','license');return `
${ctaLinkHtml(pro,'btn btn-primary btn-sm','license')}
`} +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(); diff --git a/tests/e2e/commercial.spec.js b/tests/e2e/commercial.spec.js index 63cb2b55..a19ba145 100644 --- a/tests/e2e/commercial.spec.js +++ b/tests/e2e/commercial.spec.js @@ -280,7 +280,7 @@ test('local dashboard keeps generic Pro and Team CTAs out of settings', async ({ await openView(page, 'settings'); 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' })).toHaveCount(0); + await expect(licensePanel.getByRole('link', { name: 'Start 3-day Pro trial' })).toBeVisible(); await expect(licensePanel.getByRole('link', { name: 'Start 3-day Team trial' })).toHaveCount(0); await expect(licensePanel).not.toContainText('Support continued Engraphis development with Pro.'); @@ -464,8 +464,8 @@ 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'); - // Upgrade CTAs belong with individual locked features, not the general settings panel. - await expect(licensePanel.getByRole('link', { name: 'Subscribe to Pro' })).toHaveCount(0); + // 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); diff --git a/tests/test_dashboard_auth_placement.py b/tests/test_dashboard_auth_placement.py index 49d79a5f..1c7251e9 100644 --- a/tests/test_dashboard_auth_placement.py +++ b/tests/test_dashboard_auth_placement.py @@ -540,9 +540,10 @@ def test_a_lapsed_customer_with_no_readable_plan_still_gets_a_billing_target(tmp @pytest.mark.skipif(shutil.which("node") is None, reason="node is required to run the UI") @pytest.mark.parametrize("state,expected,absent", [ - # Upgrade CTAs belong to their respective feature cards, not the general settings panel. - ("inactive", "", "Subscribe to Pro"), - ("trial_expired", "", "Subscribe to Pro"), + # The header plan badge opens this panel, so inactive and expired accounts keep the + # matching actionable destination here as well. + ("inactive", "Start 3-day Pro trial", "Subscribe to Pro"), + ("trial_expired", "Subscribe to Pro", "Start 3-day Pro trial"), ("trial", "Open Engraphis Cloud", "Start 3-day Pro trial"), ("active", "Open Engraphis Cloud", "Start 3-day Pro trial"), ]) @@ -556,10 +557,7 @@ def test_each_access_state_offers_the_one_action_that_can_succeed( "available": state == "inactive", "ends_at": 0}}, }])[state]["html"] - if expected: - assert expected in html - else: - assert html == "" + assert expected in html assert absent not in html diff --git a/tests/test_graph_engine_asset.py b/tests/test_graph_engine_asset.py index 06db98e1..249b62fc 100644 --- a/tests/test_graph_engine_asset.py +++ b/tests/test_graph_engine_asset.py @@ -1294,6 +1294,32 @@ def test_unfreezing_releases_nodes_pinned_by_dragging() -> None: assert report["released"] == {}, "unfreezing left a dragged node immovable" +@requires_node +def test_unfreezing_restores_the_live_simulation_budget_after_a_frozen_render() -> None: + """A render while frozen must not leave its one-tick budget behind on unfreeze.""" + + report = _run_engine( + """ + const api = G.create(el, {}); + api.setData(chain(2)); + api.freeze(true); + api.setData(chain(3)); + const frozen = { + time: store.cooldownTime, ticks: store.cooldownTicks, warmup: store.warmupTicks, + }; + api.freeze(false); + emit({ + frozen, + resumed: { + time: store.cooldownTime, ticks: store.cooldownTicks, warmup: store.warmupTicks, + }, + }); + """ + ) + assert report["frozen"] == {"time": 0, "ticks": 1, "warmup": 0} + assert report["resumed"] == {"time": 2200, "ticks": 160, "warmup": 40} + + @requires_node def test_freeze_is_the_physics_gate_even_with_reduced_motion() -> None: """The switch must never claim physics is live while an OS preference disables it.""" From 08c445a58e042c5838bdc77acbe6c6ee409dcb13 Mon Sep 17 00:00:00 2001 From: Jaixii Date: Mon, 3 Aug 2026 02:43:35 -0400 Subject: [PATCH 4/6] fix: start classic graph physics by default --- engraphis/classic_assets/dashboard.js | 7 +++++-- engraphis/static/dashboard.js | 7 +++++-- tests/e2e/graph-engine.spec.js | 19 ++++++++++++++++++ tests/test_graph_engine_asset.py | 29 +++++++++++++++++++++++---- 4 files changed, 54 insertions(+), 8 deletions(-) diff --git a/engraphis/classic_assets/dashboard.js b/engraphis/classic_assets/dashboard.js index 21bc75d9..34f33713 100644 --- a/engraphis/classic_assets/dashboard.js +++ b/engraphis/classic_assets/dashboard.js @@ -582,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 @@ -733,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(); @@ -746,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); diff --git a/engraphis/static/dashboard.js b/engraphis/static/dashboard.js index 21bc75d9..34f33713 100644 --- a/engraphis/static/dashboard.js +++ b/engraphis/static/dashboard.js @@ -582,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 @@ -733,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(); @@ -746,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); diff --git a/tests/e2e/graph-engine.spec.js b/tests/e2e/graph-engine.spec.js index c22b125b..32ae1893 100644 --- a/tests/e2e/graph-engine.spec.js +++ b/tests/e2e/graph-engine.spec.js @@ -445,6 +445,25 @@ test('a physics slider moves the layout under the opt-in engine', async ({ page expect(await positions()).not.toBe(settled); }); +test('reduced visual motion does not start the opt-in graph frozen', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await openDashboard(page, { query: '?graph-engine=next' }); + await openGraphView(page); + await page.waitForFunction(() => window.__fg && window.__fg.graphData().nodes + .every(node => Number.isFinite(node.x) && Number.isFinite(node.y))); + + const positions = () => page.evaluate(() => window.__fg.graphData().nodes + .map(node => ({ x: node.x, y: node.y }))); + const started = await positions(); + await page.waitForTimeout(750); + const live = await positions(); + + const greatestMovement = Math.max(...live.map((node, index) => Math.hypot( + node.x - started[index].x, node.y - started[index].y, + ))); + expect(greatestMovement).toBeGreaterThan(0.5); +}); + test('the canonical engine limits CSP violations to vendor stylesheets', async ({ page }) => { /* Opening the graph is *not* CSP-clean and this PR does not make it so: force-graph injects a handful of `