persnally 2.5.3 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -193,6 +193,32 @@
193
193
  .delta .sep { display: none; } /* stacked on phones — separators only read inline */
194
194
  }
195
195
  @media (prefers-reduced-motion: reduce) { .reveal { animation: none; opacity: 1; transform: none; } .read.fresh { animation: none; } }
196
+
197
+ /* ── hero intro (plain-language "what am I looking at") ── */
198
+ .hero-sub { font-size: 14.5px; color: var(--dim); margin-top: 14px; max-width: 64ch; line-height: 1.65; }
199
+ .hero-sub b { color: var(--text); font-weight: 600; }
200
+
201
+ /* ── shareable portrait card ── */
202
+ .modal { position: fixed; inset: 0; z-index: 50; display: none; align-items: center; justify-content: center;
203
+ padding: 24px; background: rgba(5,6,8,0.80); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
204
+ .modal.open { display: flex; }
205
+ .modal-inner { background: var(--panel); border: 1px solid var(--line-2); border-radius: 16px; width: 100%;
206
+ max-width: 860px; max-height: 92vh; overflow: auto; padding: 22px 24px; display: grid; gap: 20px; grid-template-columns: 1fr; }
207
+ @media (min-width: 760px) { .modal-inner { grid-template-columns: minmax(0,330px) 1fr; align-items: start; } }
208
+ .modal-head { grid-column: 1 / -1; display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
209
+ .modal-head h3 { font-size: 16px; font-weight: 600; }
210
+ .modal-head .x { background: none; border: none; color: var(--dim); font-size: 24px; cursor: pointer; line-height: 1; }
211
+ .modal-head .x:hover { color: var(--text); }
212
+ .card-preview { border-radius: 12px; overflow: hidden; border: 1px solid var(--line); background: #000; align-self: start; }
213
+ .card-preview canvas { display: block; width: 100%; height: auto; }
214
+ .card-ctrls { display: flex; flex-direction: column; gap: 18px; }
215
+ .card-ctrls .sub { font-size: 13px; color: var(--dim); line-height: 1.6; }
216
+ .toggles { display: flex; flex-direction: column; gap: 11px; }
217
+ .tog { display: flex; align-items: center; gap: 10px; font-size: 14px; color: var(--text); cursor: pointer; user-select: none; }
218
+ .tog input { width: 16px; height: 16px; accent-color: #FFFFFF; cursor: pointer; }
219
+ .card-actions { display: flex; gap: 10px; flex-wrap: wrap; }
220
+ .privacy-note { font-size: 11.5px; color: var(--faint); line-height: 1.65; }
221
+ .privacy-note b { color: var(--dim); }
196
222
  </style>
197
223
  </head>
198
224
  <body>
@@ -206,7 +232,8 @@
206
232
  <a class="btn ghost" href="https://github.com/sidpan2011/persnally" target="_blank" rel="noopener">GitHub <span class="arr">↗</span></a>
207
233
  <a class="btn ghost" href="https://persnally.com" target="_blank" rel="noopener">persnally.com <span class="arr">↗</span></a>
208
234
  <button class="btn ghost" id="reflect">Reflect</button>
209
- <button class="btn" id="synthesize">Re-synthesize</button>
235
+ <button class="btn ghost" id="synthesize">Re-synthesize</button>
236
+ <button class="btn" id="shareBtn">Share portrait <span class="arr" style="opacity:.75">✦</span></button>
210
237
  </div>
211
238
  </header>
212
239
 
@@ -215,6 +242,7 @@
215
242
  <div class="hero reveal" style="animation-delay:.05s">
216
243
  <div class="scale-beat" id="scaleBeat"></div>
217
244
  <div class="archetype" id="archetype">Reading your context…</div>
245
+ <div class="hero-sub">Built from <b>your own AI history</b> — your tools read this so they stop treating you like a stranger, and <b>every byte stays on your machine</b>.</div>
218
246
  <div class="gen-meta" id="genMeta"></div>
219
247
  </div>
220
248
 
@@ -234,7 +262,7 @@
234
262
  <div class="view-toggle"><button id="vGraph" class="on">map</button><button id="vList">list</button></div>
235
263
  </div>
236
264
  <div class="constellation-wrap">
237
- <div class="constellation" id="graphHost"><canvas id="graph"></canvas><div class="legend" id="legend"></div><div class="ghint">drag · pinch or scroll to zoom</div></div>
265
+ <div class="constellation" id="graphHost"><canvas id="graph"></canvas><div class="legend" id="legend"></div><div class="ghint">interests by strength · drag · zoom</div></div>
238
266
  <div class="card" id="topicList" style="display:none;padding:6px 14px"><table id="topics"></table></div>
239
267
  <div class="node-pop" id="nodePop"></div>
240
268
  </div>
@@ -262,6 +290,30 @@
262
290
  </div>
263
291
  <div class="preview-ribbon" id="ribbon">preview data — start <code>persnallyd</code> and reload to see your own</div>
264
292
 
293
+ <div class="modal" id="shareModal" aria-hidden="true">
294
+ <div class="modal-inner" role="dialog" aria-label="Share your portrait">
295
+ <div class="modal-head">
296
+ <h3>Share your portrait</h3>
297
+ <button class="x" id="shareClose" aria-label="Close">×</button>
298
+ </div>
299
+ <div class="card-preview"><canvas id="cardCanvas"></canvas></div>
300
+ <div class="card-ctrls">
301
+ <div class="sub">A snapshot of who your AI knows you to be. Pick what's on it — drawn here on your machine, never uploaded.</div>
302
+ <div class="toggles">
303
+ <label class="tog"><input type="checkbox" id="tHead" checked> Headline — your archetype</label>
304
+ <label class="tog"><input type="checkbox" id="tInt" checked> Top interests</label>
305
+ <label class="tog"><input type="checkbox" id="tVoice" checked> How you write</label>
306
+ <label class="tog"><input type="checkbox" id="tStats" checked> Stats</label>
307
+ </div>
308
+ <div class="card-actions">
309
+ <button class="btn" id="cardDownload">Download PNG</button>
310
+ <button class="btn ghost" id="cardCopy">Copy image</button>
311
+ </div>
312
+ <div class="privacy-note"><b>Nothing is uploaded</b> — the image is rendered locally and saved by you. The footer credits persnally.com so others can find it.</div>
313
+ </div>
314
+ </div>
315
+ </div>
316
+
265
317
  <script>
266
318
  "use strict";
267
319
  const $ = (id) => document.getElementById(id);
@@ -488,16 +540,37 @@ function renderMap(topics) {
488
540
  const resize = () => { w = host.clientWidth; h = window.innerWidth < 640 ? 380 : 520; canvas.width = w*dpr; canvas.height = h*dpr; canvas.style.height = h+"px"; };
489
541
  resize();
490
542
 
491
- const maxW = Math.max(...data.map(t=>t.weight), 0.01);
492
- const cats = [...new Set(data.map(t=>t.category))];
493
- const cx = w/2, cy = h/2;
494
- // each category gets an anchor on a ring → nodes cluster by color (readable + useful)
495
- const anchor = {};
496
- cats.forEach((c,i) => { const a=(i/cats.length)*Math.PI*2 - Math.PI/2; anchor[c]={ x:cx+Math.cos(a)*Math.min(w,h)*0.30, y:cy+Math.sin(a)*Math.min(w,h)*0.30 }; });
497
- const nodes = data.map((t,i) => { const an=anchor[t.category]; return { t, i, color:catColor(t.category),
498
- x:an.x+(Math.random()-0.5)*70, y:an.y+(Math.random()-0.5)*70, vx:0, vy:0, r:8+Math.sqrt(t.weight/maxW)*18 }; });
543
+ const INTERESTS = data.slice(0, 12);
544
+ const maxW = Math.max(...INTERESTS.map(t=>t.weight), 0.01);
545
+ const cx = w/2, cy = h/2, ring = Math.min(w,h)*0.27;
546
+ const lerp = (a,b,t)=>a+(b-a)*t;
547
+ const warmRGB = (t)=>[Math.round(lerp(255,200,t)),Math.round(lerp(198,140,t)),Math.round(lerp(120,70,t))]; // curated amber: bright gold (strongest) → bronze (lighter), one warm family
548
+ const rgba = (c,a)=>`rgba(${c[0]},${c[1]},${c[2]},${a})`;
549
+ const lighten = (c,a)=>[Math.min(255,c[0]+a),Math.min(255,c[1]+a),Math.min(255,c[2]+a)];
550
+ // radial portrait: YOU at the center, interests radiating by strength, their entities as leaf nodes
551
+ const center = { kind:"you", x:cx, y:cy, vx:0, vy:0, r:23, rgb:[255,209,130], fixed:true };
552
+ const interests = INTERESTS.map((t,i) => {
553
+ const ang = (i/INTERESTS.length)*Math.PI*2 - Math.PI/2;
554
+ return { kind:"topic", t, ang, x:cx+Math.cos(ang)*ring, y:cy+Math.sin(ang)*ring, vx:0, vy:0,
555
+ r:9+Math.sqrt(t.weight/maxW)*15, rgb:warmRGB(INTERESTS.length>1 ? i/(INTERESTS.length-1) : 0) };
556
+ });
557
+ const leaves = [];
558
+ interests.forEach((nd, ii) => {
559
+ (nd.t.entities||[]).slice(0,2).forEach((e,k,arr) => {
560
+ const a = nd.ang + (k-(arr.length-1)/2)*0.55;
561
+ leaves.push({ kind:"entity", label:e, x:cx+Math.cos(a)*ring*1.65, y:cy+Math.sin(a)*ring*1.65, vx:0, vy:0, r:4, rgb:[176,196,224], parent:1+ii });
562
+ });
563
+ });
564
+ const nodes = [center, ...interests, ...leaves];
565
+ const I0 = 1, L0 = 1 + interests.length;
499
566
  const edges = [];
500
- for (let i=0;i<nodes.length;i++) for (let j=i+1;j<nodes.length;j++) { const ei=nodes[i].t.entities||[], ej=nodes[j].t.entities||[]; const s=ei.filter(e=>ej.includes(e)).length; if (s) edges.push([i,j,s]); }
567
+ interests.forEach((_,ii) => edges.push([0, I0+ii, "trunk"]));
568
+ // synapses: interests that share an entity wire together → a neural web, not just a tree
569
+ for (let i=0;i<interests.length;i++) for (let j=i+1;j<interests.length;j++) {
570
+ const ei=interests[i].t.entities||[], ej=interests[j].t.entities||[];
571
+ if (ei.length && ej.length && ei.some(e=>ej.includes(e))) edges.push([I0+i, I0+j, "synapse"]);
572
+ }
573
+ leaves.forEach((lf,li) => edges.push([lf.parent, L0+li, "branch"]));
501
574
  const neighbors = nodes.map(()=>new Set());
502
575
  edges.forEach(([a,b])=>{ neighbors[a].add(b); neighbors[b].add(a); });
503
576
 
@@ -507,63 +580,87 @@ function renderMap(topics) {
507
580
 
508
581
  function tick() {
509
582
  let energy = 0;
510
- for (let i=0;i<nodes.length;i++) {
511
- const A=nodes[i]; if (i===dragNode) continue;
583
+ for (let i=1;i<nodes.length;i++) {
584
+ const A=nodes[i]; if (i===dragNode || A.fixed) continue;
512
585
  for (let j=0;j<nodes.length;j++) { if (i===j) continue; const B=nodes[j];
513
586
  let dx=A.x-B.x, dy=A.y-B.y, d2=dx*dx+dy*dy||1, d=Math.sqrt(d2);
514
- const rep=Math.min(6200/d2, 7); dx/=d; dy/=d; A.vx+=dx*rep; A.vy+=dy*rep;
515
- const minD=A.r+B.r+22; if (d<minD) { const p=(minD-d)/2; A.x+=dx*p; A.y+=dy*p; }
587
+ const rep=Math.min(5000/d2, 6); dx/=d; dy/=d; A.vx+=dx*rep; A.vy+=dy*rep;
588
+ const minD=A.r+B.r+14; if (d<minD) { const p=(minD-d)/2; A.x+=dx*p; A.y+=dy*p; }
516
589
  }
517
- const an=anchor[A.t.category]; A.vx+=(an.x-A.x)*0.014; A.vy+=(an.y-A.y)*0.014; // cluster pull
518
590
  }
519
- edges.forEach(([a,b]) => { const A=nodes[a],B=nodes[b]; let dx=B.x-A.x,dy=B.y-A.y,d=Math.hypot(dx,dy)||1; const f=(d-120)*0.008; dx/=d;dy/=d;
520
- if (a!==dragNode){A.vx+=dx*f;A.vy+=dy*f;} if (b!==dragNode){B.vx-=dx*f;B.vy-=dy*f;} });
521
- nodes.forEach((n,i) => { if (i===dragNode) return; n.vx*=0.85; n.vy*=0.85; n.x+=n.vx; n.y+=n.vy; energy+=n.vx*n.vx+n.vy*n.vy; });
591
+ interests.forEach((nd) => { if (nd===nodes[dragNode]) return; const tx=cx+Math.cos(nd.ang)*ring, ty=cy+Math.sin(nd.ang)*ring; nd.vx+=(tx-nd.x)*0.022; nd.vy+=(ty-nd.y)*0.022; });
592
+ leaves.forEach((lf) => { if (lf===nodes[dragNode]) return; const p=nodes[lf.parent]; const a=Math.atan2(p.y-cy,p.x-cx)||0; const tx=p.x+Math.cos(a)*44, ty=p.y+Math.sin(a)*44; lf.vx+=(tx-lf.x)*0.05; lf.vy+=(ty-lf.y)*0.05; });
593
+ for (let i=1;i<nodes.length;i++) { const n=nodes[i]; if (i===dragNode || n.fixed) continue; n.vx*=0.84; n.vy*=0.84; n.x+=n.vx; n.y+=n.vy; energy+=n.vx*n.vx+n.vy*n.vy; }
522
594
  return energy;
523
595
  }
524
- function draw() {
596
+ function draw(now) {
597
+ now = now || performance.now(); const T = now * 0.001;
525
598
  ctx.setTransform(dpr,0,0,dpr,0,0); ctx.clearRect(0,0,w,h);
526
- const vg=ctx.createRadialGradient(w/2,h*0.42,0,w/2,h*0.42,Math.max(w,h)*0.6); vg.addColorStop(0,"rgba(255,255,255,0.03)"); vg.addColorStop(1,"rgba(0,0,0,0)"); ctx.fillStyle=vg; ctx.fillRect(0,0,w,h);
599
+ // backdrop: faint warm core light, cool fall-off — depth, not flat black
600
+ const bg=ctx.createRadialGradient(w/2,h*0.46,0,w/2,h*0.46,Math.max(w,h)*0.72);
601
+ bg.addColorStop(0,"rgba(255,176,96,0.05)"); bg.addColorStop(0.55,"rgba(16,18,22,0)"); bg.addColorStop(1,"rgba(6,7,9,0.55)");
602
+ ctx.fillStyle=bg; ctx.fillRect(0,0,w,h);
603
+ // gentle perpetual drift — alive without bouncing; used everywhere so nothing desyncs
604
+ const amp = reduced?0:2.4;
605
+ const P = nodes.map((n,i)=>({ x:n.x+((n.fixed||i===dragNode)?0:Math.sin(T*0.5+i*1.1)*amp), y:n.y+((n.fixed||i===dragNode)?0:Math.cos(T*0.42+i*1.7)*amp) }));
527
606
  ctx.save(); ctx.translate(cam.x,cam.y); ctx.scale(cam.k,cam.k);
528
- // curved gradient edges
529
- edges.forEach(([a,b]) => { const A=nodes[a],B=nodes[b]; const active=hover===-1||hover===a||hover===b;
530
- let dx=B.x-A.x,dy=B.y-A.y,d=Math.hypot(dx,dy)||1; const off=d*0.12, mx=(A.x+B.x)/2+(-dy/d)*off, my=(A.y+B.y)/2+(dx/d)*off;
531
- const al = hover===-1 ? "33" : (active ? "aa" : "10");
532
- const g=ctx.createLinearGradient(A.x,A.y,B.x,B.y); g.addColorStop(0,A.color+al); g.addColorStop(1,B.color+al);
533
- ctx.strokeStyle=g; ctx.lineWidth=(active&&hover!==-1?1.8:1)/cam.k; ctx.beginPath(); ctx.moveTo(A.x,A.y); ctx.quadraticCurveTo(mx,my,B.x,B.y); ctx.stroke(); });
534
- // glowing nodes
535
- nodes.forEach((n,i) => { const active=hover===-1||hover===i||neighbors[hover].has(i); ctx.globalAlpha=active?1:0.18;
536
- const halo=ctx.createRadialGradient(n.x,n.y,0,n.x,n.y,n.r*2.6); halo.addColorStop(0,n.color+"bb"); halo.addColorStop(0.45,n.color+"33"); halo.addColorStop(1,n.color+"00");
537
- ctx.fillStyle=halo; ctx.beginPath(); ctx.arc(n.x,n.y,n.r*2.6,0,Math.PI*2); ctx.fill();
538
- ctx.fillStyle=n.color; ctx.shadowColor=n.color; ctx.shadowBlur=i===hover?22:9; ctx.beginPath(); ctx.arc(n.x,n.y,n.r,0,Math.PI*2); ctx.fill(); ctx.shadowBlur=0;
539
- ctx.fillStyle="rgba(255,255,255,0.9)"; ctx.beginPath(); ctx.arc(n.x-n.r*0.28,n.y-n.r*0.28,n.r*0.26,0,Math.PI*2); ctx.fill(); });
607
+ // connections thin, elegant; warm trunks, synapses, faint dendrites
608
+ edges.forEach(([a,b,kind]) => { const A=P[a],B=P[b]; const active=hover===-1||hover===a||hover===b||neighbors[hover].has(a)||neighbors[hover].has(b);
609
+ let dx=B.x-A.x,dy=B.y-A.y,d=Math.hypot(dx,dy)||1; const off=d*0.09, mx=(A.x+B.x)/2+(-dy/d)*off, my=(A.y+B.y)/2+(dx/d)*off;
610
+ if (kind==="trunk") { const al=hover===-1?0.4:(active?0.8:0.06); const g=ctx.createLinearGradient(A.x,A.y,B.x,B.y); g.addColorStop(0,rgba([255,150,80],al)); g.addColorStop(1,rgba(nodes[b].rgb,al*0.7)); ctx.strokeStyle=g; ctx.lineWidth=(active&&hover!==-1?2:1.1)/cam.k; }
611
+ else if (kind==="synapse") { const al=hover===-1?0.12:(active?0.42:0.03); const g=ctx.createLinearGradient(A.x,A.y,B.x,B.y); g.addColorStop(0,rgba(nodes[a].rgb,al)); g.addColorStop(1,rgba(nodes[b].rgb,al)); ctx.strokeStyle=g; ctx.lineWidth=0.9/cam.k; }
612
+ else { const al=hover===-1?0.16:(active?0.45:0.03); ctx.strokeStyle=rgba(nodes[a].rgb,al); ctx.lineWidth=0.8/cam.k; }
613
+ ctx.beginPath(); ctx.moveTo(A.x,A.y); ctx.quadraticCurveTo(mx,my,B.x,B.y); ctx.stroke(); });
614
+ // restrained, steady glow
615
+ ctx.globalCompositeOperation="lighter";
616
+ nodes.forEach((n,i) => { const active=hover===-1||hover===i||neighbors[hover].has(i); const k=active?1:0.1; const R=n.r*2.2;
617
+ const halo=ctx.createRadialGradient(P[i].x,P[i].y,0,P[i].x,P[i].y,R); halo.addColorStop(0,rgba(n.rgb,0.4*k)); halo.addColorStop(0.5,rgba(n.rgb,0.1*k)); halo.addColorStop(1,rgba(n.rgb,0));
618
+ ctx.fillStyle=halo; ctx.beginPath(); ctx.arc(P[i].x,P[i].y,R,0,Math.PI*2); ctx.fill(); });
619
+ ctx.globalCompositeOperation="source-over";
620
+ // refined cores — soft inner light + thin rim, no glossy bead
621
+ nodes.forEach((n,i) => { const active=hover===-1||hover===i||neighbors[hover].has(i); ctx.globalAlpha=active?1:0.22;
622
+ const x=P[i].x, y=P[i].y, r=n.r;
623
+ const cg=ctx.createRadialGradient(x-r*0.3,y-r*0.35,r*0.1,x,y,r); cg.addColorStop(0,rgba(lighten(n.rgb,45),1)); cg.addColorStop(1,rgba(n.rgb,1));
624
+ ctx.fillStyle=cg; ctx.beginPath(); ctx.arc(x,y,r,0,Math.PI*2); ctx.fill();
625
+ ctx.lineWidth=1/cam.k; ctx.strokeStyle=rgba(lighten(n.rgb,70),i===hover?0.9:0.4); ctx.stroke(); });
540
626
  ctx.globalAlpha=1; ctx.restore();
541
- // labels in screen space (constant size, collision-free, biggest first)
542
- ctx.font="600 12px "+FONT; ctx.textAlign="center"; const placed=[];
627
+ // labels clean, tracking the drifted node; entities only on hover
628
+ ctx.textAlign="center"; const placed=[];
543
629
  nodes.map((_,i)=>i).sort((a,b)=>nodes[b].r-nodes[a].r).forEach(i => { const n=nodes[i];
544
630
  const active = hover===-1 || hover===i || neighbors[hover].has(i);
545
- const force = i===hover || (hover!==-1 && neighbors[hover].has(i));
631
+ if (n.kind==="entity" && hover===-1) return;
546
632
  if (!active && hover!==-1) return;
547
- const sx=W2Sx(n.x), sy=W2Sy(n.y), sr=n.r*cam.k, tw=ctx.measureText(n.t.topic).width;
633
+ const label = n.kind==="you" ? "you" : (n.kind==="topic" ? n.t.topic : n.label);
634
+ ctx.font = (n.kind==="you" ? "600 12.5px " : "500 11.5px ") + FONT;
635
+ const sx=W2Sx(P[i].x), sy=W2Sy(P[i].y), sr=n.r*cam.k, tw=ctx.measureText(label).width;
636
+ const force = i===hover || n.kind==="you" || (hover!==-1 && neighbors[hover].has(i));
548
637
  const rect={x:sx-tw/2-3,y:sy+sr+3,w:tw+6,h:15};
549
638
  const hit=placed.some(p=>!(rect.x+rect.w<p.x||rect.x>p.x+p.w||rect.y+rect.h<p.y||rect.y>p.y+p.h));
550
639
  if (hit && !force) return; placed.push(rect);
551
- ctx.globalAlpha = hover===-1?0.92:(active?1:0.2); ctx.fillStyle=i===hover?"#FFFFFF":"#CFD2D8"; ctx.fillText(n.t.topic, sx, sy+sr+15);
640
+ ctx.globalAlpha = hover===-1?(n.kind==="you"?1:0.82):(active?1:0.2); ctx.fillStyle = (i===hover||n.kind==="you")?"#FFFFFF":"#C7CBD2"; ctx.fillText(label, sx, sy+sr+15);
552
641
  });
553
642
  ctx.globalAlpha=1;
554
643
  }
555
- function loop() { if (gen!==mapGen) return; const e=tick(); frames++; draw(); if ((e<0.02 && frames>40) || frames>360) alive=false; if (alive) requestAnimationFrame(loop); }
556
- if (reduced) { for (let s=0;s<200;s++) tick(); draw(); alive=false; } else requestAnimationFrame(loop);
557
- function wake() { if (!alive && gen===mapGen) { alive=true; frames=320; requestAnimationFrame(loop); } }
644
+ let physicsSettled=false;
645
+ function loop(now) { if (gen!==mapGen) return;
646
+ if (!physicsSettled) { const e=tick(); frames++; if ((e<0.02 && frames>40) || frames>360) physicsSettled=true; }
647
+ draw(now);
648
+ if (!reduced || !physicsSettled) requestAnimationFrame(loop); else alive=false; // keep firing once settled (paused automatically when the tab is hidden)
649
+ }
650
+ if (reduced) { for (let s=0;s<200;s++) tick(); physicsSettled=true; draw(performance.now()); alive=false; } else { alive=true; requestAnimationFrame(loop); }
651
+ function wake() { if (!alive && gen===mapGen) { alive=true; physicsSettled=false; frames=320; requestAnimationFrame(loop); } }
558
652
 
559
- $("legend").innerHTML = cats.map(c => `<span><i style="color:${catColor(c)}"></i><span style="color:var(--dim)">${esc(c)}</span></span>`).join("");
653
+ $("legend").innerHTML = `<span><i style="color:#FF9637"></i><span style="color:var(--dim)">you</span></span><span><i style="color:#FFB454"></i><span style="color:var(--dim)">strongest</span></span><span><i style="color:#6EAAFF"></i><span style="color:var(--dim)">lighter</span></span><span><i style="color:#BCD4FF"></i><span style="color:var(--dim)">the specifics</span></span>`;
560
654
 
561
655
  const pop=$("nodePop");
562
656
  const nodeAt=(mx,my)=>{ const wx=S2Wx(mx),wy=S2Wy(my); for (let i=nodes.length-1;i>=0;i--) if (Math.hypot(wx-nodes[i].x,wy-nodes[i].y)<=nodes[i].r+8/cam.k) return i; return -1; };
563
657
  const rel=(e)=>{ const r=canvas.getBoundingClientRect(); return [e.clientX-r.left, e.clientY-r.top]; };
564
- function showPop(n) { const p=nodes[n].t;
565
- pop.innerHTML=`<div class="npt">${esc(p.topic)}</div><div class="npm">${esc(p.category)} · ${esc(p.dominant_intent)} · weight ${p.weight.toFixed(2)} · ${p.signals}×</div>${(p.entities||[]).length?`<div class="npe">${esc(p.entities.slice(0,4).join(", "))}</div>`:""}`;
566
- pop.style.left=Math.min(W2Sx(nodes[n].x)+14,w-250)+"px"; pop.style.top=(W2Sy(nodes[n].y)+14)+"px"; pop.classList.add("show"); }
658
+ function showPop(n) { const nd=nodes[n];
659
+ if (nd.kind==="topic") { const p=nd.t;
660
+ pop.innerHTML=`<div class="npt">${esc(p.topic)}</div><div class="npm">${esc(p.category)} · ${esc(p.dominant_intent)} · weight ${p.weight.toFixed(2)} · ${p.signals}×</div>${(p.entities||[]).length?`<div class="npe">${esc(p.entities.slice(0,4).join(", "))}</div>`:""}`;
661
+ } else if (nd.kind==="entity") { pop.innerHTML=`<div class="npt">${esc(nd.label)}</div><div class="npm">a specific within ${esc((nodes[nd.parent].t||{}).topic||"this interest")}</div>`;
662
+ } else { pop.innerHTML=`<div class="npt">you</div><div class="npm">${interests.length} interests · ${leaves.length} specifics, sized by strength</div>`; }
663
+ pop.style.left=Math.min(W2Sx(nd.x)+14,w-250)+"px"; pop.style.top=(W2Sy(nd.y)+14)+"px"; pop.classList.add("show"); }
567
664
 
568
665
  // Pointer events unify mouse + touch + pen; a second pointer drives pinch-zoom, a tap inspects.
569
666
  const pts=new Map();
@@ -645,6 +742,7 @@ async function loadAll() {
645
742
  renderReads(reads, total);
646
743
  renderEngage(activity);
647
744
  renderReflections(assertions);
745
+ setCardData(profile, topics, voice, stats);
648
746
  saveSnapshot(topics);
649
747
  }
650
748
 
@@ -667,6 +765,146 @@ setInterval(async () => {
667
765
  if (activity) renderEngage(activity);
668
766
  }, 25000);
669
767
 
768
+ /* ── shareable portrait card (drawn locally on a canvas; nothing uploaded) ── */
769
+ let CARD = null;
770
+ const CARD_W = 1080, CARD_H = 1350;
771
+ function rrect(ctx, x, y, w, h, r) {
772
+ ctx.beginPath(); ctx.moveTo(x+r,y); ctx.arcTo(x+w,y,x+w,y+h,r); ctx.arcTo(x+w,y+h,x,y+h,r);
773
+ ctx.arcTo(x,y+h,x,y,r); ctx.arcTo(x,y,x+w,y,r); ctx.closePath();
774
+ }
775
+ function wrapLines(ctx, text, maxW) {
776
+ const out = []; let line = "";
777
+ for (const word of String(text).split(/\s+/)) {
778
+ const test = line ? line + " " + word : word;
779
+ if (ctx.measureText(test).width > maxW && line) { out.push(line); line = word; } else line = test;
780
+ }
781
+ if (line) out.push(line); return out;
782
+ }
783
+ function condenseVoice(voice) {
784
+ if (!voice || !voice.items) return [];
785
+ const picks = voice.items.filter((it) => ["voice","format","emphasis"].includes(it.dimension))
786
+ .sort((a,b) => (b.confidence||0) - (a.confidence||0))
787
+ .map((it) => it.pattern.split("—")[0].split(";")[0].split("(")[0].trim());
788
+ return [...new Set(picks)].slice(0, 4);
789
+ }
790
+ function setCardData(profile, topics, voice, stats) {
791
+ const s = stats || {};
792
+ CARD = {
793
+ headline: (profile && profile.headline) || "A portrait in progress.",
794
+ interests: (topics||[]).slice(0,6).map((t) => ({ label: t.topic, color: catColor(t.category) })),
795
+ voice: condenseVoice(voice),
796
+ signals: s.total || 0,
797
+ days: s.first ? Math.max(1, Math.round((new Date(s.last||Date.now()) - new Date(s.first)) / 86400000)) : 0,
798
+ };
799
+ }
800
+ function buildCard(canvas, opts) {
801
+ const c = CARD || { headline:"", interests:[], voice:[], signals:0, days:0 };
802
+ const dpr = 2, W = CARD_W, H = CARD_H, PAD = 84;
803
+ canvas.width = W*dpr; canvas.height = H*dpr; canvas.style.aspectRatio = (W/H).toFixed(4);
804
+ const ctx = canvas.getContext("2d"); ctx.setTransform(dpr,0,0,dpr,0,0);
805
+ ctx.textBaseline = "alphabetic"; ctx.textAlign = "left";
806
+ ctx.fillStyle = "#0B0C0E"; ctx.fillRect(0,0,W,H);
807
+ // ambient glows tinted by the user's own interest colors — on-brand, "looks good on camera"
808
+ const cols = c.interests.map((i) => i.color);
809
+ [[W*0.20,H*0.15,cols[0]||"#5EC8FF"],[W*0.86,H*0.30,cols[1]||"#FFB454"],[W*0.5,H*0.95,cols[2]||"#B388FF"]]
810
+ .forEach(([x,y,col]) => { const g=ctx.createRadialGradient(x,y,0,x,y,W*0.55); g.addColorStop(0,col+"26"); g.addColorStop(1,col+"00"); ctx.fillStyle=g; ctx.fillRect(0,0,W,H); });
811
+ ctx.strokeStyle = "rgba(255,255,255,0.10)"; ctx.lineWidth = 2; rrect(ctx,28,28,W-56,H-56,20); ctx.stroke();
812
+
813
+ let y = PAD + 6;
814
+ ctx.fillStyle = "#F1F2F4"; ctx.font = "600 30px "+FONT; ctx.fillText("persnally", PAD, y);
815
+ ctx.fillStyle = "#35D07F"; ctx.beginPath(); ctx.arc(PAD + ctx.measureText("persnally").width + 15, y-9, 5, 0, Math.PI*2); ctx.fill();
816
+ ctx.textAlign = "right"; ctx.fillStyle = "#9A9DA5"; ctx.font = "500 22px "+FONT; ctx.fillText("a portrait of me", W-PAD, y); ctx.textAlign = "left";
817
+ y += 50;
818
+
819
+ if (opts.head && c.headline) {
820
+ let fs = 54, lines;
821
+ do { ctx.font = "700 "+fs+"px "+FONT; lines = wrapLines(ctx, c.headline, W-PAD*2); fs -= 3; } while (lines.length > 4 && fs > 30);
822
+ fs += 3; ctx.font = "700 "+fs+"px "+FONT; ctx.fillStyle = "#FFFFFF";
823
+ const lh = fs*1.18;
824
+ for (const ln of lines.slice(0,4)) { y += lh; ctx.fillText(ln, PAD, y); }
825
+ y += 24;
826
+ }
827
+
828
+ if (opts.int && c.interests.length) {
829
+ y += 30; ctx.fillStyle = "#9A9DA5"; ctx.font = "600 19px "+FONT; ctx.fillText("WHAT I'M INTO", PAD, y); y += 36;
830
+ let x = PAD; const ch = 52, gap = 13; ctx.font = "600 24px "+FONT;
831
+ for (const it of c.interests) {
832
+ const cw = ctx.measureText(it.label).width + 50;
833
+ if (x + cw > W-PAD) { x = PAD; y += ch + gap; }
834
+ ctx.fillStyle = it.color+"22"; ctx.strokeStyle = it.color+"99"; ctx.lineWidth = 1.5; rrect(ctx,x,y,cw,ch,ch/2); ctx.fill(); ctx.stroke();
835
+ ctx.fillStyle = it.color; ctx.beginPath(); ctx.arc(x+22, y+ch/2, 5, 0, Math.PI*2); ctx.fill();
836
+ ctx.fillStyle = "#F1F2F4"; ctx.fillText(it.label, x+38, y+ch/2+8);
837
+ x += cw + gap;
838
+ }
839
+ y += ch + 26;
840
+ }
841
+
842
+ if (opts.voice && c.voice.length) {
843
+ y += 24; ctx.fillStyle = "#9A9DA5"; ctx.font = "600 19px "+FONT; ctx.fillText("HOW I WRITE", PAD, y); y += 38;
844
+ ctx.fillStyle = "#C7CACF"; ctx.font = "400 26px "+FONT;
845
+ for (const ln of wrapLines(ctx, c.voice.join(" · "), W-PAD*2).slice(0,2)) { ctx.fillText(ln, PAD, y); y += 38; }
846
+ }
847
+
848
+ // constellation — the brand's signature visual, drawn from the same interest colors;
849
+ // fills the lower band so the card reads full in every toggle state.
850
+ if (c.interests.length >= 2) {
851
+ const bandTop = y + 30, bandBottom = H - PAD - 104;
852
+ if (bandBottom - bandTop > 130) {
853
+ const cxb = W/2, cyb = (bandTop + bandBottom) / 2;
854
+ const spread = Math.min(W - PAD*2, bandBottom - bandTop) * 0.46;
855
+ const ns = c.interests.map((it, i) => {
856
+ const a = i * 2.39996323, rad = spread * Math.sqrt((i + 0.5) / c.interests.length);
857
+ return { x: cxb + Math.cos(a)*rad, y: cyb + Math.sin(a)*rad*0.62, r: 30 - i*3.2, color: it.color };
858
+ });
859
+ for (let i = 0; i < ns.length; i++) for (let j = i+1; j < ns.length; j++) {
860
+ const A = ns[i], B = ns[j], d = Math.hypot(A.x-B.x, A.y-B.y);
861
+ if (d > spread*1.05) continue;
862
+ const g = ctx.createLinearGradient(A.x,A.y,B.x,B.y); g.addColorStop(0,A.color+"40"); g.addColorStop(1,B.color+"40");
863
+ const off = d*0.14, mx = (A.x+B.x)/2 + (-(B.y-A.y)/d)*off, my = (A.y+B.y)/2 + ((B.x-A.x)/d)*off;
864
+ ctx.strokeStyle = g; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(A.x,A.y); ctx.quadraticCurveTo(mx,my,B.x,B.y); ctx.stroke();
865
+ }
866
+ for (const n of ns) {
867
+ const halo = ctx.createRadialGradient(n.x,n.y,0,n.x,n.y,n.r*2.8);
868
+ halo.addColorStop(0,n.color+"cc"); halo.addColorStop(0.45,n.color+"33"); halo.addColorStop(1,n.color+"00");
869
+ ctx.fillStyle = halo; ctx.beginPath(); ctx.arc(n.x,n.y,n.r*2.8,0,Math.PI*2); ctx.fill();
870
+ ctx.fillStyle = n.color; ctx.beginPath(); ctx.arc(n.x,n.y,n.r,0,Math.PI*2); ctx.fill();
871
+ ctx.fillStyle = "rgba(255,255,255,0.9)"; ctx.beginPath(); ctx.arc(n.x-n.r*0.3, n.y-n.r*0.3, n.r*0.28, 0, Math.PI*2); ctx.fill();
872
+ }
873
+ }
874
+ }
875
+
876
+ if (opts.stats) {
877
+ const parts = [];
878
+ if (c.signals) parts.push(fmtN(c.signals)+" signals");
879
+ if (c.days) parts.push(fmtN(c.days)+" days");
880
+ parts.push("100% on my machine");
881
+ ctx.fillStyle = "#62656D"; ctx.font = "500 22px "+FONT; ctx.fillText(parts.join(" · "), PAD, H-PAD-58);
882
+ }
883
+ ctx.fillStyle = "#9A9DA5"; ctx.font = "500 22px "+FONT; ctx.fillText("made with persnally — your own context engine", PAD, H-PAD);
884
+ ctx.textAlign = "right"; ctx.fillStyle = "#62656D"; ctx.fillText("persnally.com", W-PAD, H-PAD); ctx.textAlign = "left";
885
+ }
886
+ const _modal = $("shareModal");
887
+ function renderCard() { buildCard($("cardCanvas"), { head:$("tHead").checked, int:$("tInt").checked, voice:$("tVoice").checked, stats:$("tStats").checked }); }
888
+ function openShare() { renderCard(); _modal.classList.add("open"); _modal.setAttribute("aria-hidden","false"); }
889
+ function closeShare() { _modal.classList.remove("open"); _modal.setAttribute("aria-hidden","true"); }
890
+ $("shareBtn").onclick = openShare;
891
+ $("shareClose").onclick = closeShare;
892
+ _modal.addEventListener("click", (e) => { if (e.target === _modal) closeShare(); });
893
+ ["tHead","tInt","tVoice","tStats"].forEach((id) => $(id).addEventListener("change", renderCard));
894
+ addEventListener("keydown", (e) => { if (e.key === "Escape" && _modal.classList.contains("open")) closeShare(); });
895
+ $("cardDownload").onclick = () => $("cardCanvas").toBlob((b) => {
896
+ if (!b) return; const u = URL.createObjectURL(b), a = document.createElement("a");
897
+ a.href = u; a.download = "persnally-portrait.png"; a.click(); setTimeout(() => URL.revokeObjectURL(u), 1000);
898
+ }, "image/png");
899
+ $("cardCopy").onclick = async () => {
900
+ const btn = $("cardCopy");
901
+ try {
902
+ const b = await new Promise((r) => $("cardCanvas").toBlob(r, "image/png"));
903
+ await navigator.clipboard.write([new ClipboardItem({ "image/png": b })]);
904
+ btn.textContent = "Copied ✓"; setTimeout(() => btn.textContent = "Copy image", 1600);
905
+ } catch { btn.textContent = "Copy unsupported — Download"; setTimeout(() => btn.textContent = "Copy image", 2400); }
906
+ };
907
+
670
908
  loadAll();
671
909
 
672
910
  /* ── demo data ── */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "persnally",
3
- "version": "2.5.3",
3
+ "version": "2.6.0",
4
4
  "license": "FSL-1.1-MIT",
5
5
  "description": "Your own context engine — local-first, across every AI. So every AI finally knows you.",
6
6
  "type": "module",