From 8f2adb65d6c155f402fa8360e809721b5fb9ad8d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 27 Aug 2026 20:10:12 +0000 Subject: [PATCH 1/3] Add a persisted line-thickness slider for all link views Follow-up to the Code canvas work in #82/#88. eorroe asked for a top-left slider that sets edge thickness across Graph, Code, and the other link-drawing views. Default stays 1 so current lines look the same. The value lives in localStorage under codeflow-ui-prefs. Co-authored-by: Braedon Saunders --- index.html | 128 +++++++++++++++++++++++++++++++------ tests/code-canvas.test.mjs | 58 +++++++++++++++++ 2 files changed, 166 insertions(+), 20 deletions(-) diff --git a/index.html b/index.html index c447aa2..571d5e4 100644 --- a/index.html +++ b/index.html @@ -5627,6 +5627,75 @@ var ANALYSIS_CACHE_VERSION=2; var ANALYSIS_CACHE_MAX=12; var ANALYSIS_CACHE_MAX_BYTES=18*1024*1024; +var UI_PREFS_STORAGE_KEY='codeflow-ui-prefs'; +var LINE_THICKNESS_MIN=1; +var LINE_THICKNESS_MAX=6; +var LINE_THICKNESS_DEFAULT=1; + +function clampLineThickness(value){ + var n=Number(value); + if(!isFinite(n))return LINE_THICKNESS_DEFAULT; + n=Math.round(n); + if(nLINE_THICKNESS_MAX)return LINE_THICKNESS_MAX; + return n; +} + +function defaultUiPrefs(){ + return{lineThickness:LINE_THICKNESS_DEFAULT}; +} + +function normalizeUiPrefs(prefs){ + prefs=prefs&&typeof prefs==='object'?prefs:{}; + var next=defaultUiPrefs(); + if(prefs.lineThickness!=null)next.lineThickness=clampLineThickness(prefs.lineThickness); + return next; +} + +function readUiPrefs(storage){ + try{ + if(!storage||typeof storage.getItem!=='function')return defaultUiPrefs(); + var raw=storage.getItem(UI_PREFS_STORAGE_KEY); + if(!raw)return defaultUiPrefs(); + return normalizeUiPrefs(JSON.parse(raw)); + }catch(e){ + return defaultUiPrefs(); + } +} + +function writeUiPrefs(storage,prefs){ + var next=normalizeUiPrefs(Object.assign({},readUiPrefs(storage),prefs||{})); + try{ + if(storage&&typeof storage.setItem==='function')storage.setItem(UI_PREFS_STORAGE_KEY,JSON.stringify(next)); + }catch(e){} + return next; +} + +function graphLinkBaseWidth(count){ + return Math.max(1,Math.min(2,Math.sqrt(count||1)*0.3)); +} + +function graphLinkStrokeWidth(count,thickness){ + return graphLinkBaseWidth(count)*clampLineThickness(thickness); +} + +function scaleStrokeWidth(base,thickness){ + var n=Number(base); + if(!isFinite(n)||n<=0)n=1; + return Math.max(0.4,n*clampLineThickness(thickness)); +} + +function graph3dLinkWidth(link,selectedPath,thickness){ + link=link||{}; + var baseWidth=Math.max(0.8,Math.min(3,Math.sqrt(link.count||1)*0.4)); + if(selectedPath){ + var s=link.source&&(link.source.id||link.source); + var t=link.target&&(link.target.id||link.target); + if(s===selectedPath||t===selectedPath)return scaleStrokeWidth(baseWidth*2,thickness); + return scaleStrokeWidth(baseWidth*0.3,thickness); + } + return scaleStrokeWidth(baseWidth,thickness); +} function escapeRegExp(value){ return String(value||'').replace(/[.*+?^${}()|[\]\\]/g,'\\$&'); @@ -7608,6 +7677,9 @@ var _pillScroll=useState(0),codePillScroll=_pillScroll[0],setCodePillScroll=_pillScroll[1]; var _codeExpand=useState(false),codeViewExpand=_codeExpand[0],setCodeViewExpand=_codeExpand[1]; var _codeWrap=useState(true),codeViewWrap=_codeWrap[0],setCodeViewWrap=_codeWrap[1]; + var _lineThick=useState(readUiPrefs(window.localStorage).lineThickness),lineThickness=_lineThick[0],setLineThickness=_lineThick[1]; + var lineThicknessRef=useRef(lineThickness); + lineThicknessRef.current=lineThickness; var pendingRecentDeleteTimerRef=useRef(null); var zipInputRef=useRef(null); var zipArchiveRef=useRef(null); @@ -9193,6 +9265,20 @@ if(nodesRef.current)nodesRef.current.attr('transform',function(d){return'translate('+d.x+','+d.y+')';}); if(linksRef.current)linksRef.current.attr('d',graphLinkPath); } + function persistLineThickness(value){ + var next=writeUiPrefs(window.localStorage,{lineThickness:value}).lineThickness; + setLineThickness(next); + } + function applyLinkThickness(){ + var thickness=lineThicknessRef.current; + if(linksRef.current){ + linksRef.current.attr('stroke-width',function(d){return graphLinkStrokeWidth(d.count,thickness);}); + } + var g3=graph3dInstanceRef.current; + if(g3&&typeof g3.linkWidth==='function'){ + g3.linkWidth(function(link){return graph3dLinkWidth(link,selectedPathRef.current,thickness);}); + } + } function flyToOpenedCodeCard(place){ if(!svgRef.current||!zoomRef.current||!place||!isFinite(place.x)||!isFinite(place.y))return; var w=svgRef.current.clientWidth||800; @@ -9465,7 +9551,7 @@ var velDecay=isLargeGraph?0.7:0.6; sim.velocityDecay(velDecay).alphaDecay(alphaDecay); simRef.current=sim; - var link=linkLayer.selectAll('path').data(links).join('path').attr('fill','none').attr('stroke',theme==='light'?'#ccc':'#333').attr('stroke-width',function(d){return Math.max(1,Math.min(2,Math.sqrt(d.count)*0.3));}).attr('stroke-opacity',0.4).attr('marker-end','url(#arr)'); + var link=linkLayer.selectAll('path').data(links).join('path').attr('fill','none').attr('stroke',theme==='light'?'#ccc':'#333').attr('stroke-width',function(d){return graphLinkStrokeWidth(d.count,lineThicknessRef.current);}).attr('stroke-opacity',0.4).attr('marker-end','url(#arr)'); linksRef.current=link; var node=nodeLayer.selectAll('g').data(nodes).join('g').style('cursor','pointer'); nodesRef.current=node; @@ -9603,6 +9689,10 @@ if(updateHullsRef.current)updateHullsRef.current(); },[codeViewFiles,graphConfig.vizType,graphConfig.linkDist,codeViewExpand,codeViewWrap]); + useEffect(function(){ + applyLinkThickness(); + },[lineThickness,selected&&selected.path]); + // 3D Force Graph Hook useEffect(function(){ if(!data||!graph3dRef.current||graphConfig.vizType!=='graph3d')return; @@ -9743,14 +9833,7 @@ return theme==='light'?'rgba(200,200,200,0.4)':'rgba(60,60,70,0.4)'; }) .linkWidth(function(link){ - var s=link.source.id||link.source; - var t=link.target.id||link.target; - var baseWidth=Math.max(0.8,Math.min(3,Math.sqrt(link.count)*0.4)); - if(selected){ - if(s===selected.path||t===selected.path)return baseWidth*2.0; - return baseWidth*0.3; - } - return baseWidth; + return graph3dLinkWidth(link,selected&&selected.path,lineThicknessRef.current); }) .linkDirectionalArrowLength(function(link){ if(selected){ @@ -10126,7 +10209,7 @@ var tooltip=container.append('div').attr('class','treemap-tooltip').style('display','none').style('position','absolute'); g.selectAll('path.dendro-link').data(root.links()).join('path').attr('class','dendro-link') .attr('d',function(d){return'M'+d.source.y+','+d.source.x+'C'+(d.source.y+d.target.y)/2+','+d.source.x+' '+(d.source.y+d.target.y)/2+','+d.target.x+' '+d.target.y+','+d.target.x;}) - .attr('fill','none').attr('stroke','var(--border)').attr('stroke-width',1.5).attr('stroke-opacity',0.6); + .attr('fill','none').attr('stroke','var(--border)').attr('stroke-width',scaleStrokeWidth(1.5,lineThickness)).attr('stroke-opacity',0.6); var node=g.selectAll('g.dendro-node').data(root.descendants()).join('g').attr('class','dendro-node') .attr('transform',function(d){return'translate('+d.y+','+d.x+')';}).style('cursor','pointer'); node.append('circle').attr('r',function(d){return d.children?6:8;}) @@ -10154,7 +10237,7 @@ if(d.data.path&&selectFileRef.current)selectFileRef.current(d.data.path); else if(d.data.fullPath)filterByFolder(d.data.fullPath); }); - },[data,graphConfig.vizType,colorMap,folderFilter]); + },[data,graphConfig.vizType,colorMap,folderFilter,lineThickness]); // Sankey Diagram - Flow visualization showing dependencies between folders useEffect(function(){ @@ -10217,7 +10300,7 @@ g.selectAll('path.sankey-link').data(graph.links).join('path').attr('class','sankey-link') .attr('d',d3.sankeyLinkHorizontal()).attr('fill','none') .attr('stroke',function(d){return colorMap[d.source.fullPath]||COLORS[d.source.id%COLORS.length];}) - .attr('stroke-width',function(d){return Math.max(2,d.width);}).attr('stroke-opacity',0.4) + .attr('stroke-width',function(d){return scaleStrokeWidth(Math.max(2,d.width),lineThickness);}).attr('stroke-opacity',0.4) .on('mouseenter',function(e,d){ d3.select(this).attr('stroke-opacity',0.8); tooltip.html(renderTooltipHtml(d.source.name+' → '+d.target.name,[ @@ -10240,7 +10323,7 @@ g.selectAll('path.sankey-link').attr('stroke-opacity',function(l){return l.source.id===d.id||l.target.id===d.id?0.8:0.1;}); }).on('mouseleave',function(){tooltip.style('display','none');g.selectAll('path.sankey-link').attr('stroke-opacity',0.4);}) .on('click',function(e,d){e.stopPropagation();filterByFolder(d.fullPath);}); - },[data,graphConfig.vizType,colorMap,folderFilter]); + },[data,graphConfig.vizType,colorMap,folderFilter,lineThickness]); // Disjoint Force-Directed - Separate clusters per folder useEffect(function(){ @@ -10282,7 +10365,7 @@ .attr('x',function(d,i){return(i%cols)*cellW+20;}).attr('y',function(d,i){return Math.floor(i/cols)*cellH+28;}) .attr('fill','var(--t2)').attr('font-size','11px').attr('font-weight','600').text(function(d){return d.split('/').pop()||'root';}); var link=g.selectAll('line.disjoint-link').data(links).join('line').attr('class','disjoint-link') - .attr('stroke','var(--border)').attr('stroke-width',1).attr('stroke-opacity',0.3); + .attr('stroke','var(--border)').attr('stroke-width',scaleStrokeWidth(1,lineThickness)).attr('stroke-opacity',0.3); var tooltip=container.append('div').attr('class','treemap-tooltip').style('display','none').style('position','absolute'); var node=g.selectAll('g.disjoint-node').data(nodes).join('g').attr('class','disjoint-node').style('cursor','pointer') .call(d3.drag().on('start',function(e,d){if(!e.active)sim.alphaTarget(0.3).restart();d.fx=d.x;d.fy=d.y;}) @@ -10310,7 +10393,7 @@ }); svg.on('click',function(){setSelected(null);setBlastRadius(null);}); return function(){sim.stop();}; - },[data,graphConfig.vizType,colorMap,folderFilter]); + },[data,graphConfig.vizType,colorMap,folderFilter,lineThickness]); // Circular Bundle visualization - Interactive with zoom, selection, blast radius useEffect(function(){ @@ -10371,7 +10454,7 @@ return'M'+x1+','+y1+'Q'+cx+','+cy+' '+x2+','+y2; }) .attr('fill','none').attr('stroke',getBundleLinkColor) - .attr('stroke-width',1.8).attr('stroke-opacity',0.35); + .attr('stroke-width',scaleStrokeWidth(1.8,lineThickness)).attr('stroke-opacity',0.35); var tooltip=container.append('div').attr('class','treemap-tooltip').style('display','none').style('position','absolute'); var node=mainG.selectAll('g.bundle-node').data(nodes).join('g').attr('class','bundle-node').style('cursor','pointer') .attr('transform',function(d){return'rotate('+(d.angle*180/Math.PI-90)+') translate('+radius+',0)'+(d.angle>Math.PI?' rotate(180)':'');}); @@ -10382,7 +10465,7 @@ function applyBundleDefaultState(){ link.transition().duration(200) .attr('stroke-opacity',0.35) - .attr('stroke-width',1.8) + .attr('stroke-width',scaleStrokeWidth(1.8,lineThickness)) .attr('stroke',getBundleLinkColor); node.selectAll('.bundle-circle').transition().duration(200) .attr('fill',function(d){return colorMap[d.folder]||COLORS[0];}) @@ -10395,7 +10478,7 @@ var directConnections=getBundleDirectConnections(nodeId); link.transition().duration(200) .attr('stroke-opacity',function(linkDatum){return isBundleLinkMatch(nodeId,linkDatum)?0.88:0.04;}) - .attr('stroke-width',function(linkDatum){return isBundleLinkMatch(nodeId,linkDatum)?3.1:1;}) + .attr('stroke-width',function(linkDatum){return scaleStrokeWidth(isBundleLinkMatch(nodeId,linkDatum)?3.1:1,lineThickness);}) .attr('stroke',function(linkDatum){return isBundleLinkMatch(nodeId,linkDatum)?'var(--acc)':getBundleLinkColor(linkDatum);}); node.selectAll('.bundle-circle').transition().duration(200) .attr('opacity',function(nodeDatum){return directConnections.has(nodeDatum.id)?1:0.22;}) @@ -10408,7 +10491,7 @@ var affectedSet=new Set(blast&&blast.affected?blast.affected:[]); link.transition().duration(300) .attr('stroke-opacity',function(linkDatum){return isBundleLinkMatch(nodeId,linkDatum)?0.96:0.08;}) - .attr('stroke-width',function(linkDatum){return isBundleLinkMatch(nodeId,linkDatum)?3.6:1.15;}) + .attr('stroke-width',function(linkDatum){return scaleStrokeWidth(isBundleLinkMatch(nodeId,linkDatum)?3.6:1.15,lineThickness);}) .attr('stroke',function(linkDatum){return isBundleLinkMatch(nodeId,linkDatum)?'#ff9f43':getBundleLinkColor(linkDatum);}); node.selectAll('.bundle-circle').transition().duration(300) .attr('fill',function(nodeDatum){return nodeDatum.id===nodeId?'#ff5f5f':affectedSet.has(nodeDatum.id)?'#ff9f43':colorMap[nodeDatum.folder]||COLORS[0];}) @@ -10466,7 +10549,7 @@ }else{ applyBundleDefaultState(); } - },[data,graphConfig.vizType,colorMap,folderFilter,selected,blastRadius]); + },[data,graphConfig.vizType,colorMap,folderFilter,selected,blastRadius,lineThickness]); function zoomIn(){ if(graphConfig.vizType==='graph3d'&&graph3dInstanceRef.current){ @@ -11673,6 +11756,11 @@ React.createElement('input',{type:'range',className:'config-slider',min:'30',max:'200',value:graphConfig.linkDist,onChange:function(e){setGraphConfig(Object.assign({},graphConfig,{linkDist:parseInt(e.target.value)}));}}), React.createElement('span',{className:'config-value'},graphConfig.linkDist) ), + React.createElement('div',{className:'config-row'}, + React.createElement('span',{className:'config-label'},'Thickness'), + React.createElement('input',{type:'range',className:'config-slider',min:String(LINE_THICKNESS_MIN),max:String(LINE_THICKNESS_MAX),step:'1',value:lineThickness,'aria-label':'Line thickness',onChange:function(e){persistLineThickness(e.target.value);}}), + React.createElement('span',{className:'config-value'},lineThickness) + ), React.createElement('div',{className:'graph-config-title',style:{marginTop:8}},'Display'), React.createElement('label',{className:'config-check'}, React.createElement('input',{type:'checkbox',checked:graphConfig.showLabels,onChange:function(e){setGraphConfig(Object.assign({},graphConfig,{showLabels:e.target.checked}));}}), diff --git a/tests/code-canvas.test.mjs b/tests/code-canvas.test.mjs index 8cd647c..b9c3b99 100644 --- a/tests/code-canvas.test.mjs +++ b/tests/code-canvas.test.mjs @@ -1273,4 +1273,62 @@ test('index.html ships a working Code view, not a stub', () => { assert.match(htmlSource, /\.code-card\.expand:not\(\.wrap\) \.code-card-body\{overflow-x:auto/); assert.doesNotMatch(htmlSource, /sidebar-title'\},'Color By'/); assert.doesNotMatch(htmlSource, /sidebar-title'\},'Explorer'/); + assert.match(htmlSource, /function persistLineThickness\(/); + assert.match(htmlSource, /function applyLinkThickness\(/); + assert.match(htmlSource, /'aria-label':'Line thickness'/); + assert.match(htmlSource, /config-label'\},'Thickness'/); + assert.match(htmlSource, /persistLineThickness\(e\.target\.value\)/); + assert.match(htmlSource, /graphLinkStrokeWidth\(d\.count,lineThicknessRef\.current\)/); + assert.match(htmlSource, /graph3dLinkWidth\(link,selected&&selected\.path,lineThicknessRef\.current\)/); + assert.match(htmlSource, /scaleStrokeWidth\(1\.5,lineThickness\)/); + assert.match(htmlSource, /scaleStrokeWidth\(Math\.max\(2,d\.width\),lineThickness\)/); + assert.match(htmlSource, /UI_PREFS_STORAGE_KEY/); +}); + +function memoryStorage(seed) { + const data = Object.assign({}, seed || {}); + return { + getItem(key) { + return Object.prototype.hasOwnProperty.call(data, key) ? data[key] : null; + }, + setItem(key, value) { + data[key] = String(value); + }, + _data: data + }; +} + +test('line thickness defaults match current graph edges and stay in range', () => { + assert.equal(context.LINE_THICKNESS_DEFAULT, 1); + assert.equal(context.LINE_THICKNESS_MIN, 1); + assert.equal(context.LINE_THICKNESS_MAX, 6); + assert.equal(context.clampLineThickness(undefined), 1); + assert.equal(context.clampLineThickness('nope'), 1); + assert.equal(context.clampLineThickness(0), 1); + assert.equal(context.clampLineThickness(9), 6); + assert.equal(context.clampLineThickness(3.6), 4); + const thin = context.graphLinkStrokeWidth(1, 1); + const thick = context.graphLinkStrokeWidth(1, 4); + assert.equal(thin, Math.max(1, Math.min(2, Math.sqrt(1) * 0.3))); + assert.equal(thick, thin * 4); + assert.equal(context.scaleStrokeWidth(1.5, 1), 1.5); + assert.equal(context.scaleStrokeWidth(1.5, 2), 3); + const idle = context.graph3dLinkWidth({ count: 1, source: 'a.js', target: 'b.js' }, null, 1); + const selected = context.graph3dLinkWidth({ count: 1, source: 'a.js', target: 'b.js' }, 'a.js', 1); + assert.ok(selected > idle); +}); + +test('UI prefs persist line thickness in localStorage', () => { + const storage = memoryStorage(); + assert.equal(context.readUiPrefs(storage).lineThickness, 1); + assert.equal(context.readUiPrefs(null).lineThickness, 1); + const written = context.writeUiPrefs(storage, { lineThickness: 5 }); + assert.equal(written.lineThickness, 5); + assert.equal(context.readUiPrefs(storage).lineThickness, 5); + assert.equal(context.writeUiPrefs(storage, { lineThickness: 99 }).lineThickness, 6); + storage.setItem(context.UI_PREFS_STORAGE_KEY, '{not-json'); + assert.equal(context.readUiPrefs(storage).lineThickness, 1); + const other = memoryStorage({ [context.UI_PREFS_STORAGE_KEY]: JSON.stringify({ lineThickness: 2, extra: true }) }); + const merged = context.writeUiPrefs(other, { lineThickness: 3 }); + assert.equal(merged.lineThickness, 3); }); From 5349d6786cf83b65d21c2f2415c5870cec7a245a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 28 Aug 2026 00:07:49 +0000 Subject: [PATCH 2/3] Guard localStorage so a blocked store cannot blank the app Accessing window.localStorage at the App useState and persist call sites threw SecurityError before readUiPrefs/writeUiPrefs could catch it. Resolve storage inside the helpers, keep the in-session thickness default at 1 when storage is blocked, and cover the throwing-getter case in tests. Co-authored-by: Braedon Saunders --- index.html | 29 ++++++++++++++++++++++----- tests/code-canvas.test.mjs | 40 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/index.html b/index.html index 571d5e4..7423961 100644 --- a/index.html +++ b/index.html @@ -5652,10 +5652,24 @@ return next; } +function resolveUiPrefsStorage(storage){ + try{ + if(storage===undefined){ + if(typeof window==='undefined')return null; + storage=window.localStorage; + } + if(!storage||typeof storage.getItem!=='function')return null; + return storage; + }catch(e){ + return null; + } +} + function readUiPrefs(storage){ try{ - if(!storage||typeof storage.getItem!=='function')return defaultUiPrefs(); - var raw=storage.getItem(UI_PREFS_STORAGE_KEY); + var store=resolveUiPrefsStorage(storage); + if(!store)return defaultUiPrefs(); + var raw=store.getItem(UI_PREFS_STORAGE_KEY); if(!raw)return defaultUiPrefs(); return normalizeUiPrefs(JSON.parse(raw)); }catch(e){ @@ -5666,11 +5680,16 @@ function writeUiPrefs(storage,prefs){ var next=normalizeUiPrefs(Object.assign({},readUiPrefs(storage),prefs||{})); try{ - if(storage&&typeof storage.setItem==='function')storage.setItem(UI_PREFS_STORAGE_KEY,JSON.stringify(next)); + var store=resolveUiPrefsStorage(storage); + if(store)store.setItem(UI_PREFS_STORAGE_KEY,JSON.stringify(next)); }catch(e){} return next; } +function persistUiPrefs(prefs){ + return writeUiPrefs(undefined,prefs); +} + function graphLinkBaseWidth(count){ return Math.max(1,Math.min(2,Math.sqrt(count||1)*0.3)); } @@ -7677,7 +7696,7 @@ var _pillScroll=useState(0),codePillScroll=_pillScroll[0],setCodePillScroll=_pillScroll[1]; var _codeExpand=useState(false),codeViewExpand=_codeExpand[0],setCodeViewExpand=_codeExpand[1]; var _codeWrap=useState(true),codeViewWrap=_codeWrap[0],setCodeViewWrap=_codeWrap[1]; - var _lineThick=useState(readUiPrefs(window.localStorage).lineThickness),lineThickness=_lineThick[0],setLineThickness=_lineThick[1]; + var _lineThick=useState(readUiPrefs().lineThickness),lineThickness=_lineThick[0],setLineThickness=_lineThick[1]; var lineThicknessRef=useRef(lineThickness); lineThicknessRef.current=lineThickness; var pendingRecentDeleteTimerRef=useRef(null); @@ -9266,7 +9285,7 @@ if(linksRef.current)linksRef.current.attr('d',graphLinkPath); } function persistLineThickness(value){ - var next=writeUiPrefs(window.localStorage,{lineThickness:value}).lineThickness; + var next=persistUiPrefs({lineThickness:value}).lineThickness; setLineThickness(next); } function applyLinkThickness(){ diff --git a/tests/code-canvas.test.mjs b/tests/code-canvas.test.mjs index b9c3b99..443a1c1 100644 --- a/tests/code-canvas.test.mjs +++ b/tests/code-canvas.test.mjs @@ -1274,10 +1274,16 @@ test('index.html ships a working Code view, not a stub', () => { assert.doesNotMatch(htmlSource, /sidebar-title'\},'Color By'/); assert.doesNotMatch(htmlSource, /sidebar-title'\},'Explorer'/); assert.match(htmlSource, /function persistLineThickness\(/); + assert.match(htmlSource, /function persistUiPrefs\(/); + assert.match(htmlSource, /function resolveUiPrefsStorage\(/); assert.match(htmlSource, /function applyLinkThickness\(/); assert.match(htmlSource, /'aria-label':'Line thickness'/); assert.match(htmlSource, /config-label'\},'Thickness'/); assert.match(htmlSource, /persistLineThickness\(e\.target\.value\)/); + assert.match(htmlSource, /useState\(readUiPrefs\(\)\.lineThickness\)/); + assert.match(htmlSource, /persistUiPrefs\(\{lineThickness:value\}\)/); + assert.doesNotMatch(htmlSource, /readUiPrefs\(window\.localStorage\)/); + assert.doesNotMatch(htmlSource, /writeUiPrefs\(window\.localStorage/); assert.match(htmlSource, /graphLinkStrokeWidth\(d\.count,lineThicknessRef\.current\)/); assert.match(htmlSource, /graph3dLinkWidth\(link,selected&&selected\.path,lineThicknessRef\.current\)/); assert.match(htmlSource, /scaleStrokeWidth\(1\.5,lineThickness\)/); @@ -1331,4 +1337,38 @@ test('UI prefs persist line thickness in localStorage', () => { const other = memoryStorage({ [context.UI_PREFS_STORAGE_KEY]: JSON.stringify({ lineThickness: 2, extra: true }) }); const merged = context.writeUiPrefs(other, { lineThickness: 3 }); assert.equal(merged.lineThickness, 3); + context.window = { localStorage: storage }; + try { + assert.equal(context.persistUiPrefs({ lineThickness: 4 }).lineThickness, 4); + assert.equal(context.readUiPrefs().lineThickness, 4); + } finally { + delete context.window; + } +}); + +function throwingLocalStorageWindow() { + return { + get localStorage() { + const err = new Error('Access is denied for this document.'); + err.name = 'SecurityError'; + throw err; + } + }; +} + +test('UI prefs keep the default when localStorage access throws', () => { + context.window = throwingLocalStorageWindow(); + try { + assert.equal(context.resolveUiPrefsStorage(undefined), null); + assert.doesNotThrow(() => context.readUiPrefs()); + assert.equal(context.readUiPrefs().lineThickness, context.LINE_THICKNESS_DEFAULT); + const rendered = context.readUiPrefs().lineThickness; + assert.equal(rendered, 1); + assert.doesNotThrow(() => context.persistUiPrefs({ lineThickness: 5 })); + const inSession = context.persistUiPrefs({ lineThickness: 5 }); + assert.equal(inSession.lineThickness, 5); + assert.equal(context.readUiPrefs().lineThickness, 1); + } finally { + delete context.window; + } }); From 7840f36926ddf2868beae31c8c2839a2f740da2a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 28 Aug 2026 12:20:21 +0000 Subject: [PATCH 3/3] Show the thickness slider on Tree, Flow, Cluster, and Bundle Those views already honor lineThickness but the gear and settings panel were limited to Graph, 3D Graph, and Code. The toolbar now follows vizUsesLineThickness. Extra layout sliders stay hidden on the views that do not use them. Co-authored-by: Braedon Saunders --- index.html | 33 +++++++++++++++++++++------------ tests/code-canvas.test.mjs | 16 ++++++++++++++++ 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/index.html b/index.html index 7423961..244c3a5 100644 --- a/index.html +++ b/index.html @@ -274,6 +274,7 @@ .fn-caller{display:flex;align-items:center;gap:6px;padding:4px 6px;border-radius:4px;font-size:10px;color:var(--t2);cursor:pointer} .fn-caller:hover{background:var(--hover);color:var(--acc)} .graph-config{position:absolute;top:12px;left:180px;background:var(--bg1);border:1px solid var(--border);border-radius:8px;padding:12px;z-index:50;width:220px} +.graph-config.thickness-only{left:56px} .graph-config-title{font-size:9px;font-weight:600;color:var(--t3);text-transform:uppercase;margin-bottom:10px} .config-row{display:flex;align-items:center;gap:8px;margin-bottom:10px} .config-label{font-size:10px;color:var(--t2);min-width:60px} @@ -5984,6 +5985,14 @@ return vizType!=='code'; } +function vizUsesLineThickness(vizType){ + return vizType==='graph'||vizType==='code'||vizType==='graph3d'||vizType==='dendro'||vizType==='sankey'||vizType==='disjoint'||vizType==='bundle'; +} + +function vizHasGraphToolbar(vizType){ + return vizType==='graph'||vizType==='code'||vizType==='graph3d'; +} + function collectVisibleCodeFiles(selectedPath,data,folderFilter,limit){ if(!data||!data.files)return[]; var filtered=folderFilter?data.files.filter(function(f){return f.folder===folderFilter||f.folder.startsWith(folderFilter+'/');}):data.files; @@ -11746,16 +11755,16 @@ graphConfig.vizType==='disjoint'&&React.createElement('div',{ref:disjointRef,className:'disjoint-container',style:{width:'100%',height:'100%',position:'relative'}}), graphConfig.vizType==='bundle'&&React.createElement('div',{ref:bundleRef,className:'bundle-container'}), graphConfig.vizType==='architecture'&&renderArchitectureView(), - (graphConfig.vizType==='graph'||graphConfig.vizType==='graph3d'||graphConfig.vizType==='code')&&React.createElement('div',{className:'canvas-toolbar'}, - React.createElement('button',{className:'tool-btn',onClick:zoomIn,'aria-label':'Zoom in'},'+'), - React.createElement('button',{className:'tool-btn',onClick:zoomOut,'aria-label':'Zoom out'},'−'), - React.createElement('button',{className:'tool-btn',onClick:resetZoom,'aria-label':'Reset zoom'},'⟲'), - React.createElement('button',{className:'tool-btn',onClick:fitView,'aria-label':'Fit view'},'⊡'), + vizUsesLineThickness(graphConfig.vizType)&&React.createElement('div',{className:'canvas-toolbar'}, + vizHasGraphToolbar(graphConfig.vizType)&&React.createElement('button',{className:'tool-btn',onClick:zoomIn,'aria-label':'Zoom in'},'+'), + vizHasGraphToolbar(graphConfig.vizType)&&React.createElement('button',{className:'tool-btn',onClick:zoomOut,'aria-label':'Zoom out'},'−'), + vizHasGraphToolbar(graphConfig.vizType)&&React.createElement('button',{className:'tool-btn',onClick:resetZoom,'aria-label':'Reset zoom'},'⟲'), + vizHasGraphToolbar(graphConfig.vizType)&&React.createElement('button',{className:'tool-btn',onClick:fitView,'aria-label':'Fit view'},'⊡'), React.createElement('button',{className:'tool-btn'+(showGraphConfig?' active':''),onClick:function(){setShowGraphConfig(!showGraphConfig);},'aria-label':'Graph settings',style:showGraphConfig?{background:'var(--accbg)',borderColor:'var(--acc)'}:{}}, React.createElement(Icon,{name:'settings',size:'m'}) ) ), - (graphConfig.vizType==='graph'||graphConfig.vizType==='graph3d'||graphConfig.vizType==='code')&&showGraphConfig&&React.createElement('div',{className:'graph-config'}, + vizUsesLineThickness(graphConfig.vizType)&&showGraphConfig&&React.createElement('div',{className:'graph-config'+(vizHasGraphToolbar(graphConfig.vizType)?'':' thickness-only')}, (graphConfig.vizType==='graph'||graphConfig.vizType==='code')&&React.createElement('div',{className:'graph-config-title'},'Layout'), (graphConfig.vizType==='graph'||graphConfig.vizType==='code')&&React.createElement('div',{className:'view-toggle',style:{flexWrap:'wrap'}}, React.createElement('button',{className:'view-btn'+(graphConfig.viewMode==='force'?' active':''),onClick:function(){setGraphConfig(Object.assign({},graphConfig,{viewMode:'force'}));}},'Force'), @@ -11764,13 +11773,13 @@ React.createElement('button',{className:'view-btn'+(graphConfig.viewMode==='grid'?' active':''),onClick:function(){setGraphConfig(Object.assign({},graphConfig,{viewMode:'grid'}));}},'Grid'), React.createElement('button',{className:'view-btn'+(graphConfig.viewMode==='metro'?' active':''),onClick:function(){setGraphConfig(Object.assign({},graphConfig,{viewMode:'metro'}));}},'Metro') ), - React.createElement('div',{className:'graph-config-title',style:{marginTop:(graphConfig.vizType==='graph'||graphConfig.vizType==='code')?8:0}},'Spacing'), - React.createElement('div',{className:'config-row'}, + React.createElement('div',{className:'graph-config-title',style:{marginTop:(graphConfig.vizType==='graph'||graphConfig.vizType==='code')?8:0}},vizHasGraphToolbar(graphConfig.vizType)?'Spacing':'Lines'), + vizHasGraphToolbar(graphConfig.vizType)&&React.createElement('div',{className:'config-row'}, React.createElement('span',{className:'config-label'},'Spread'), React.createElement('input',{type:'range',className:'config-slider',min:'50',max:'500',value:graphConfig.spacing,onChange:function(e){setGraphConfig(Object.assign({},graphConfig,{spacing:parseInt(e.target.value)}));}}), React.createElement('span',{className:'config-value'},graphConfig.spacing) ), - React.createElement('div',{className:'config-row'}, + vizHasGraphToolbar(graphConfig.vizType)&&React.createElement('div',{className:'config-row'}, React.createElement('span',{className:'config-label'},'Links'), React.createElement('input',{type:'range',className:'config-slider',min:'30',max:'200',value:graphConfig.linkDist,onChange:function(e){setGraphConfig(Object.assign({},graphConfig,{linkDist:parseInt(e.target.value)}));}}), React.createElement('span',{className:'config-value'},graphConfig.linkDist) @@ -11780,12 +11789,12 @@ React.createElement('input',{type:'range',className:'config-slider',min:String(LINE_THICKNESS_MIN),max:String(LINE_THICKNESS_MAX),step:'1',value:lineThickness,'aria-label':'Line thickness',onChange:function(e){persistLineThickness(e.target.value);}}), React.createElement('span',{className:'config-value'},lineThickness) ), - React.createElement('div',{className:'graph-config-title',style:{marginTop:8}},'Display'), - React.createElement('label',{className:'config-check'}, + vizHasGraphToolbar(graphConfig.vizType)&&React.createElement('div',{className:'graph-config-title',style:{marginTop:8}},'Display'), + vizHasGraphToolbar(graphConfig.vizType)&&React.createElement('label',{className:'config-check'}, React.createElement('input',{type:'checkbox',checked:graphConfig.showLabels,onChange:function(e){setGraphConfig(Object.assign({},graphConfig,{showLabels:e.target.checked}));}}), 'Show labels' ), - React.createElement('label',{className:'config-check',style:{marginTop:6}}, + vizHasGraphToolbar(graphConfig.vizType)&&React.createElement('label',{className:'config-check',style:{marginTop:6}}, React.createElement('input',{type:'checkbox',checked:graphConfig.curvedLinks,onChange:function(e){setGraphConfig(Object.assign({},graphConfig,{curvedLinks:e.target.checked}));}}), 'Curved links' ), diff --git a/tests/code-canvas.test.mjs b/tests/code-canvas.test.mjs index 443a1c1..885c50e 100644 --- a/tests/code-canvas.test.mjs +++ b/tests/code-canvas.test.mjs @@ -1289,6 +1289,22 @@ test('index.html ships a working Code view, not a stub', () => { assert.match(htmlSource, /scaleStrokeWidth\(1\.5,lineThickness\)/); assert.match(htmlSource, /scaleStrokeWidth\(Math\.max\(2,d\.width\),lineThickness\)/); assert.match(htmlSource, /UI_PREFS_STORAGE_KEY/); + assert.match(htmlSource, /function vizUsesLineThickness\(/); + assert.match(htmlSource, /vizUsesLineThickness\(graphConfig\.vizType\)&&React\.createElement\('div',\{className:'canvas-toolbar'/); + assert.match(htmlSource, /vizUsesLineThickness\(graphConfig\.vizType\)&&showGraphConfig/); + assert.doesNotMatch(htmlSource, /\(graphConfig\.vizType==='graph'\|\|graphConfig\.vizType==='graph3d'\|\|graphConfig\.vizType==='code'\)&&React\.createElement\('div',\{className:'canvas-toolbar'/); +}); + +test('thickness control is offered on every view that draws links', () => { + ['graph', 'code', 'graph3d', 'dendro', 'sankey', 'disjoint', 'bundle'].forEach((viz) => { + assert.equal(context.vizUsesLineThickness(viz), true, viz); + }); + ['treemap', 'matrix', 'architecture', 'none', ''].forEach((viz) => { + assert.equal(context.vizUsesLineThickness(viz), false, viz); + }); + assert.equal(context.vizHasGraphToolbar('graph'), true); + assert.equal(context.vizHasGraphToolbar('dendro'), false); + assert.equal(context.vizHasGraphToolbar('bundle'), false); }); function memoryStorage(seed) {