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