feat(graph): redesign node labels as dark glass pills
Some checks are pending
CI / Test (macos-latest) (push) Waiting to run
CI / Test (ubuntu-latest) (push) Waiting to run
CI / Release Build (aarch64-apple-darwin) (push) Blocked by required conditions
CI / Release Build (x86_64-unknown-linux-gnu) (push) Blocked by required conditions
Test Suite / Unit Tests (push) Waiting to run
Test Suite / MCP E2E Tests (push) Waiting to run
Test Suite / User Journey Tests (push) Blocked by required conditions
Test Suite / Dashboard Build (push) Waiting to run
Test Suite / Code Coverage (push) Waiting to run

Labels previously rendered as near-white text (#e2e8f0) on a transparent
canvas. UnrealBloomPass (threshold 0.2) amplified every bright pixel
into a huge white halo that made labels unreadable at normal camera
distances — reported by Sam 2026-04-19 with a screenshot of the LoRA
training label blown out into a luminous blob.

New design:

- Dark rounded pill (rgba(10,16,28,0.82)) sits below the text and
  hugs its measured width. That keeps the pill background well below
  bloom threshold so the halo can't spread past the label footprint.
- Text dimmed to mid-luminance slate (#94a3b8). Still legible at the
  standard camera distance but dim enough that bloom only adds a soft
  glow instead of a blast.
- Font trimmed to 22px / weight 600 (was bold 28px); sprite scale
  tightened from 12×1.5 to 9×1.2 so labels don't visually out-compete
  the node spheres they annotate.
- Hairline slate stroke (18% alpha) on the pill for definition when
  the camera gets close.

The canvas mock in the vitest setup grew beginPath / closePath /
moveTo / lineTo / quadraticCurveTo / arc / fill / stroke / strokeText
stubs so createTextSprite can exercise the full rounded-rect path in
unit tests without a real DOM. All 251 tests stay green.
This commit is contained in:
Sam Valladares 2026-04-19 21:52:14 -05:00
parent d7f0fe03e0
commit 30d92b5371
50 changed files with 107 additions and 46 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
import{a as r}from"../chunks/BOu53idK.js";import{w as t}from"../chunks/UvrLlSZu.js";export{t as load_css,r as start};

View file

@ -0,0 +1 @@
import{a as r}from"../chunks/CK5Nmlyf.js";import{w as t}from"../chunks/DUtaznkq.js";export{t as load_css,r as start};

View file

@ -1 +1 @@
import"../chunks/Bzak7iHL.js";import"../chunks/CrlWs-6R.js";import{p as h,f as g,t as d,a as l,d as v,e as s,r as o}from"../chunks/VE8Jor13.js";import{s as p}from"../chunks/DHnEMX8z.js";import{a as _,f as x}from"../chunks/7UNxJI5L.js";import{i as $}from"../chunks/jyeIy8pa.js";import{p as m}from"../chunks/UvrLlSZu.js";import{s as k}from"../chunks/BOu53idK.js";const b={get error(){return m.error},get status(){return m.status}};k.updated.check;const i=b;var E=x("<h1> </h1> <p> </p>",1);function D(f,n){h(n,!1),$();var t=E(),r=g(t),c=s(r,!0);o(r);var a=v(r,2),u=s(a,!0);o(a),d(()=>{var e;p(c,i.status),p(u,(e=i.error)==null?void 0:e.message)}),_(f,t),l()}export{D as component};
import"../chunks/Bzak7iHL.js";import"../chunks/CrlWs-6R.js";import{p as h,f as g,t as d,a as l,d as v,e as s,r as o}from"../chunks/VE8Jor13.js";import{s as p}from"../chunks/DHnEMX8z.js";import{a as _,f as x}from"../chunks/7UNxJI5L.js";import{i as $}from"../chunks/jyeIy8pa.js";import{p as m}from"../chunks/DUtaznkq.js";import{s as k}from"../chunks/CK5Nmlyf.js";const b={get error(){return m.error},get status(){return m.status}};k.updated.check;const i=b;var E=x("<h1> </h1> <p> </p>",1);function D(f,n){h(n,!1),$();var t=E(),r=g(t),c=s(r,!0);o(r);var a=v(r,2),u=s(a,!0);o(a),d(()=>{var e;p(c,i.status),p(u,(e=i.error)==null?void 0:e.message)}),_(f,t),l()}export{D as component};

View file

@ -1 +1 @@
import"../chunks/Bzak7iHL.js";import"../chunks/CrlWs-6R.js";import{o as p}from"../chunks/DWVWfZUn.js";import{p as r,a as t}from"../chunks/VE8Jor13.js";import{i as a}from"../chunks/jyeIy8pa.js";import{g as m}from"../chunks/BOu53idK.js";function u(i,o){r(o,!1),p(()=>m("/graph",{replaceState:!0})),a(),t()}export{u as component};
import"../chunks/Bzak7iHL.js";import"../chunks/CrlWs-6R.js";import{o as p}from"../chunks/DWVWfZUn.js";import{p as r,a as t}from"../chunks/VE8Jor13.js";import{i as a}from"../chunks/jyeIy8pa.js";import{g as m}from"../chunks/CK5Nmlyf.js";function u(i,o){r(o,!1),p(()=>m("/graph",{replaceState:!0})),a(),t()}export{u as component};

View file

@ -1 +1 @@
{"version":"1776650393863"}
{"version":"1776653229849"}

View file

@ -11,13 +11,13 @@
<link rel="icon" type="image/svg+xml" href="/dashboard/favicon.svg" />
<link rel="apple-touch-icon" href="/dashboard/favicon.svg" />
<link rel="manifest" href="/dashboard/manifest.json" />
<link href="/dashboard/_app/immutable/entry/start.BieeVrE-.js" rel="modulepreload">
<link href="/dashboard/_app/immutable/chunks/BOu53idK.js" rel="modulepreload">
<link href="/dashboard/_app/immutable/entry/start.C8fl2m83.js" rel="modulepreload">
<link href="/dashboard/_app/immutable/chunks/CK5Nmlyf.js" rel="modulepreload">
<link href="/dashboard/_app/immutable/chunks/VE8Jor13.js" rel="modulepreload">
<link href="/dashboard/_app/immutable/chunks/CCRrbKqn.js" rel="modulepreload">
<link href="/dashboard/_app/immutable/chunks/UvrLlSZu.js" rel="modulepreload">
<link href="/dashboard/_app/immutable/chunks/DUtaznkq.js" rel="modulepreload">
<link href="/dashboard/_app/immutable/chunks/DWVWfZUn.js" rel="modulepreload">
<link href="/dashboard/_app/immutable/entry/app.hiopGwi-.js" rel="modulepreload">
<link href="/dashboard/_app/immutable/entry/app.B1RqXwG0.js" rel="modulepreload">
<link href="/dashboard/_app/immutable/chunks/DHnEMX8z.js" rel="modulepreload">
<link href="/dashboard/_app/immutable/chunks/7UNxJI5L.js" rel="modulepreload">
<link href="/dashboard/_app/immutable/chunks/Bzak7iHL.js" rel="modulepreload">
@ -33,7 +33,7 @@
<div style="display: contents">
<script>
{
__sveltekit_9mpvth = {
__sveltekit_1mw0ef2 = {
base: "/dashboard",
assets: "/dashboard"
};
@ -41,8 +41,8 @@
const element = document.currentScript.parentElement;
Promise.all([
import("/dashboard/_app/immutable/entry/start.BieeVrE-.js"),
import("/dashboard/_app/immutable/entry/app.hiopGwi-.js")
import("/dashboard/_app/immutable/entry/start.C8fl2m83.js"),
import("/dashboard/_app/immutable/entry/app.B1RqXwG0.js")
]).then(([kit, app]) => {
kit.start(app, element);
});

Binary file not shown.

Binary file not shown.

View file

@ -19,13 +19,24 @@ const mockContext2D = {
clearRect: vi.fn(),
fillRect: vi.fn(),
fillText: vi.fn(),
strokeText: vi.fn(),
measureText: vi.fn(() => ({ width: 100 })),
createRadialGradient: vi.fn(() => createMockGradient()),
createLinearGradient: vi.fn(() => createMockGradient()),
beginPath: vi.fn(),
closePath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
quadraticCurveTo: vi.fn(),
arc: vi.fn(),
fill: vi.fn(),
stroke: vi.fn(),
font: '',
textAlign: '',
textBaseline: '',
fillStyle: '' as string | object,
strokeStyle: '' as string | object,
lineWidth: 1,
shadowColor: '',
shadowBlur: 0,
shadowOffsetX: 0,

View file

@ -261,7 +261,7 @@ export class NodeManager {
// Text label sprite
const labelText = node.label || node.type;
const labelSprite = this.createTextSprite(labelText, '#e2e8f0');
const labelSprite = this.createTextSprite(labelText, '#94a3b8');
labelSprite.position.copy(pos);
labelSprite.position.y += size * 2 + 1.5;
labelSprite.userData = { isLabel: true, nodeId: node.id, offset: size * 2 + 1.5 };
@ -339,6 +339,20 @@ export class NodeManager {
});
}
/// Render a label as a dark rounded "pill" with dim slate text.
///
/// The scene runs an UnrealBloomPass with threshold 0.2, so any bright
/// canvas pixels get smeared into a halo. Previously the labels were
/// near-white (#e2e8f0) text on a transparent background, which bloomed
/// into unreadable white blobs (issue filed by Sam 2026-04-19). The fix:
///
/// 1. A ~85%-opaque dark pill under the text so the background is
/// well below the bloom threshold, stopping the halo before it
/// spreads past the label bounds.
/// 2. Mid-luminance slate text (#94a3b8 by default) — still legible
/// but dim enough that bloom only adds a soft glow, not a blast.
/// 3. Smaller font (22px) and tighter sprite scale (9×1.2) so labels
/// don't visually compete with the node spheres they annotate.
private createTextSprite(text: string, color: string): THREE.Sprite {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
@ -352,15 +366,51 @@ export class NodeManager {
const label = text.length > 40 ? text.slice(0, 37) + '...' : text;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.font = 'bold 28px -apple-system, BlinkMacSystemFont, sans-serif';
// Measure the label so the backing pill hugs the text instead of
// spanning the full canvas width (which would leave a giant empty
// dark bar on short labels like "fact" or "note").
ctx.font = '600 22px -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif';
const metrics = ctx.measureText(label);
const textWidth = metrics.width;
const padX = 14;
const padY = 9;
const pillW = Math.min(textWidth + padX * 2, canvas.width - 4);
const pillH = 40;
const pillX = (canvas.width - pillW) / 2;
const pillY = (canvas.height - pillH) / 2;
const radius = pillH / 2;
// Dark glass pill — low enough luminance that UnrealBloomPass at
// threshold 0.2 does not amplify its pixels.
ctx.fillStyle = 'rgba(10, 16, 28, 0.82)';
ctx.beginPath();
ctx.moveTo(pillX + radius, pillY);
ctx.lineTo(pillX + pillW - radius, pillY);
ctx.quadraticCurveTo(pillX + pillW, pillY, pillX + pillW, pillY + radius);
ctx.lineTo(pillX + pillW, pillY + pillH - radius);
ctx.quadraticCurveTo(
pillX + pillW,
pillY + pillH,
pillX + pillW - radius,
pillY + pillH
);
ctx.lineTo(pillX + radius, pillY + pillH);
ctx.quadraticCurveTo(pillX, pillY + pillH, pillX, pillY + pillH - radius);
ctx.lineTo(pillX, pillY + radius);
ctx.quadraticCurveTo(pillX, pillY, pillX + radius, pillY);
ctx.closePath();
ctx.fill();
// Hairline stroke for definition at small camera distances.
ctx.strokeStyle = 'rgba(148, 163, 184, 0.18)';
ctx.lineWidth = 1;
ctx.stroke();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.shadowColor = 'rgba(0, 0, 0, 0.8)';
ctx.shadowBlur = 6;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 2;
ctx.fillStyle = color;
ctx.fillText(label, canvas.width / 2, canvas.height / 2);
ctx.fillText(label, canvas.width / 2, canvas.height / 2 + 1);
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
@ -374,7 +424,7 @@ export class NodeManager {
});
const sprite = new THREE.Sprite(mat);
sprite.scale.set(12, 1.5, 1);
sprite.scale.set(9, 1.2, 1);
return sprite;
}