From 6a8fc3c5bc61523d7bcd747ce2313f1f1f7ba595 Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Sat, 8 Aug 2026 13:52:27 -0500 Subject: [PATCH] fix(web): keep ready subissue labels visible --- .github/workflows/ci.yml | 2 + .github/workflows/linux-bundle.yml | 1 + .github/workflows/macos-bundle.yml | 1 + .github/workflows/release.yml | 4 + .github/workflows/windows-bundle.yml | 1 + CHANGELOG.md | 4 + scripts/tests/npm-toolchain.tests.ps1 | 95 +++++++++++++++++++ web/package.json | 7 ++ web/src/lib/starmap/starmap.test.ts | 130 +++++++++++++++++++++++++- web/src/lib/starmap/starmap.ts | 19 +++- 10 files changed, 258 insertions(+), 6 deletions(-) create mode 100644 scripts/tests/npm-toolchain.tests.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f1690e..0fa827c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,7 @@ jobs: node-version: 24 cache: npm cache-dependency-path: web/package-lock.json + - run: npm install --global npm@12.0.2 - name: Install Tauri Linux build dependencies if: runner.os == 'Linux' run: | @@ -40,6 +41,7 @@ jobs: - uses: actions/setup-node@v6 with: node-version: 24 + - run: npm install --global npm@12.0.2 - run: npm --prefix web ci - run: npm --prefix web run check - run: npm --prefix web test diff --git a/.github/workflows/linux-bundle.yml b/.github/workflows/linux-bundle.yml index 30c7c9b..bf1e61d 100644 --- a/.github/workflows/linux-bundle.yml +++ b/.github/workflows/linux-bundle.yml @@ -20,6 +20,7 @@ jobs: node-version: 24 cache: npm cache-dependency-path: web/package-lock.json + - run: npm install --global npm@12.0.2 - name: Install Tauri Linux build dependencies run: | sudo apt-get update diff --git a/.github/workflows/macos-bundle.yml b/.github/workflows/macos-bundle.yml index 513b35d..93df7f4 100644 --- a/.github/workflows/macos-bundle.yml +++ b/.github/workflows/macos-bundle.yml @@ -22,6 +22,7 @@ jobs: node-version: 24 cache: npm cache-dependency-path: web/package-lock.json + - run: npm install --global npm@12.0.2 - run: npm --prefix web ci - name: Build unsigned universal macOS DMG run: bash scripts/build-macos-universal.sh Development diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 46d0d7c..4d6ffa6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,6 +23,7 @@ jobs: node-version: 24 cache: npm cache-dependency-path: web/package-lock.json + - run: npm install --global npm@12.0.2 - run: npm --prefix web ci - run: npm --prefix web run check - run: npm --prefix web test @@ -56,6 +57,7 @@ jobs: node-version: 24 cache: npm cache-dependency-path: web/package-lock.json + - run: npm install --global npm@12.0.2 - name: Fail closed unless Windows signing credentials exist shell: powershell run: .\scripts\assert-windows-signing.ps1 -CertificateBase64 $env:WINDOWS_CERTIFICATE_BASE64 -CertificatePassword $env:WINDOWS_CERTIFICATE_PASSWORD @@ -121,6 +123,7 @@ jobs: node-version: 24 cache: npm cache-dependency-path: web/package-lock.json + - run: npm install --global npm@12.0.2 - name: Import Developer ID certificate run: | keychain="$RUNNER_TEMP/stellr-signing.keychain-db" @@ -160,6 +163,7 @@ jobs: node-version: 24 cache: npm cache-dependency-path: web/package-lock.json + - run: npm install --global npm@12.0.2 - name: Install Tauri Linux build dependencies run: | sudo apt-get update diff --git a/.github/workflows/windows-bundle.yml b/.github/workflows/windows-bundle.yml index 5753c67..7ab156c 100644 --- a/.github/workflows/windows-bundle.yml +++ b/.github/workflows/windows-bundle.yml @@ -20,6 +20,7 @@ jobs: node-version: 24 cache: npm cache-dependency-path: web/package-lock.json + - run: npm install --global npm@12.0.2 - run: npm --prefix web ci - name: Verify Windows packaging contract shell: powershell diff --git a/CHANGELOG.md b/CHANGELOG.md index b43e127..4a6b580 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- Kept ready subissue labels visible and clear of their emphasis rings while + the star-map camera eases. +- Declared npm 12.0.2 as the web workspace's development package manager and + activated it across CI, bundle, and release workflows. - Made newly added repositories appear in the sidebar without restarting Stellr or performing another space action. - Emphasized the incoming and outgoing edges directly connected to a selected diff --git a/scripts/tests/npm-toolchain.tests.ps1 b/scripts/tests/npm-toolchain.tests.ps1 new file mode 100644 index 0000000..76ecfe8 --- /dev/null +++ b/scripts/tests/npm-toolchain.tests.ps1 @@ -0,0 +1,95 @@ +$ErrorActionPreference = 'Stop' + +$repo = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$package = Get-Content (Join-Path $repo 'web\package.json') -Raw | ConvertFrom-Json +$requiredVersion = $package.devEngines.packageManager.version + +if ([string]::IsNullOrWhiteSpace($requiredVersion)) { + throw 'web/package.json must declare devEngines.packageManager.version.' +} + +$setupNeedle = '- uses: actions/setup-node@v6' +$installNeedle = "- run: npm install --global npm@$requiredVersion" +$ciNeedle = '- run: npm --prefix web ci' + +function Test-NpmToolchainWorkflow { + param( + [Parameter(Mandatory)] [string] $Workflow, + [Parameter(Mandatory)] [string] $Name + ) + + $jobMatches = [regex]::Matches($Workflow, '(?m)^ (?[A-Za-z0-9_-]+):\r?$') + $count = 0 + + for ($jobIndex = 0; $jobIndex -lt $jobMatches.Count; $jobIndex++) { + $jobMatch = $jobMatches[$jobIndex] + $jobEnd = if ($jobIndex + 1 -lt $jobMatches.Count) { + $jobMatches[$jobIndex + 1].Index + } else { + $Workflow.Length + } + $job = $Workflow.Substring($jobMatch.Index, $jobEnd - $jobMatch.Index) + $jobName = $jobMatch.Groups['name'].Value + $offset = 0 + + while (($ciIndex = $job.IndexOf($ciNeedle, $offset, [System.StringComparison]::Ordinal)) -ge 0) { + $count++ + $prefix = $job.Substring(0, $ciIndex) + $setupIndex = $prefix.LastIndexOf($setupNeedle, [System.StringComparison]::Ordinal) + $installIndex = $prefix.LastIndexOf($installNeedle, [System.StringComparison]::Ordinal) + + if ($setupIndex -lt 0) { + throw "$Name job '$jobName' runs npm ci without actions/setup-node@v6." + } + if ($installIndex -lt $setupIndex) { + throw "$Name job '$jobName' runs npm ci before activating npm $requiredVersion after its Node setup." + } + + $offset = $ciIndex + $ciNeedle.Length + } + } + + return $count +} + +$crossJobFixture = @" +jobs: + configured: + steps: + $setupNeedle + $installNeedle + $ciNeedle + missing-toolchain: + steps: + $ciNeedle +"@ +$mutationRejected = $false +try { + $null = Test-NpmToolchainWorkflow -Workflow $crossJobFixture -Name 'cross-job fixture' +} catch { + if ($_.Exception.Message -like "*job 'missing-toolchain' runs npm ci*") { + $mutationRejected = $true + } else { + throw + } +} +if (-not $mutationRejected) { + throw 'Contract self-test failed: a later job borrowed npm setup from an earlier job.' +} + +$ciCount = 0 +$workflowFiles = Get-ChildItem (Join-Path $repo '.github\workflows') -File | + Where-Object Extension -In '.yml', '.yaml' +foreach ($workflowFile in $workflowFiles) { + $workflow = Get-Content $workflowFile.FullName -Raw + $ciCount += Test-NpmToolchainWorkflow -Workflow $workflow -Name $workflowFile.Name +} + +$expectedCiCount = 9 +if ($ciCount -ne $expectedCiCount) { + throw "Expected $expectedCiCount GitHub workflow npm ci steps, found $ciCount." +} + +Write-Output "NPM_TOOLCHAIN_CONTRACT_PASSED=true" +Write-Output "NPM_TOOLCHAIN_VERSION=$requiredVersion" +Write-Output "NPM_TOOLCHAIN_CI_STEPS=$ciCount" diff --git a/web/package.json b/web/package.json index a3f3303..f749ff0 100644 --- a/web/package.json +++ b/web/package.json @@ -29,5 +29,12 @@ "dompurify": "^3.4.12", "marked": "^18.0.7", "phosphor-svelte": "^3.1.0" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "12.0.2", + "onFail": "download" + } } } diff --git a/web/src/lib/starmap/starmap.test.ts b/web/src/lib/starmap/starmap.test.ts index 81b42ae..dd5dabc 100644 --- a/web/src/lib/starmap/starmap.test.ts +++ b/web/src/lib/starmap/starmap.test.ts @@ -748,11 +748,47 @@ describe('label placement', () => { // aligned, so the drawn boxes can be reconstructed. function recordingContext() { const drawn: { text: string; x: number; y: number; align: string; font: string }[] = [] + const arcs: { + x: number + y: number + radius: number + lineWidth: number + worldX: number + worldY: number + worldRadius: number + }[] = [] + let translateX = 0 + let translateY = 0 + let scale = 1 const ctx: Record = { textAlign: 'center', font: '', + lineWidth: 1, createRadialGradient: () => ({ addColorStop: () => {} }), measureText: (s: string) => ({ width: s.length * 6 }), + setTransform() { + translateX = 0 + translateY = 0 + scale = 1 + }, + translate(x: number, y: number) { + translateX += x + translateY += y + }, + scale(x: number) { + scale *= x + }, + arc(this: Record, x: number, y: number, radius: number) { + arcs.push({ + x: x * scale + translateX, + y: y * scale + translateY, + radius: radius * scale, + lineWidth: (this.lineWidth as number) * scale, + worldX: x, + worldY: y, + worldRadius: radius, + }) + }, fillText(this: Record, text: string, x: number, y: number) { drawn.push({ text, @@ -764,12 +800,12 @@ describe('label placement', () => { }, } for (const m of [ - 'setTransform', 'fillRect', 'beginPath', 'arc', 'fill', 'stroke', 'moveTo', 'lineTo', - 'closePath', 'quadraticCurveTo', 'setLineDash', 'save', 'restore', 'translate', 'scale', 'rotate', + 'fillRect', 'beginPath', 'fill', 'stroke', 'moveTo', 'lineTo', + 'closePath', 'quadraticCurveTo', 'setLineDash', 'save', 'restore', 'rotate', ]) { ctx[m] = () => {} } - return { ctx, drawn } + return { ctx, drawn, arcs } } afterEach(() => { @@ -846,6 +882,94 @@ describe('label placement', () => { } }) + it('keeps a ready subissue label visible and clear of its emphasis ring', () => { + vi.useFakeTimers({ toFake: ['performance'] }) + const { ctx, drawn, arcs } = recordingContext() + let frames: FrameRequestCallback[] = [] + HTMLCanvasElement.prototype.getContext = (() => ctx) as never + globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => { + frames.push(cb) + return frames.length + }) as never + globalThis.cancelAnimationFrame = (() => {}) as never + + const tickets: Ticket[] = [ + { num: 15, slug: '15', title: 'Milestone parent', type: 'issue', status: 'open', blockedBy: [], parentIssue: null, frontier: false }, + { num: 16, slug: '16', title: 'Current issue', type: 'issue', status: 'open', blockedBy: [], parentIssue: 15, frontier: false }, + { num: 24, slug: '24', title: 'Orbit parent', type: 'issue', status: 'open', blockedBy: [], parentIssue: 15, frontier: false }, + { num: 28, slug: '28', title: 'Add the canonical SHA-256 digest contract', type: 'task', status: 'frontier', blockedBy: [], parentIssue: 24, frontier: true }, + { num: 29, slug: '29', title: 'Adopt canonical digests', type: 'task', status: 'blocked', blockedBy: [28], parentIssue: 24, frontier: false }, + { num: 30, slug: '30', title: 'Verify the digest branch', type: 'task', status: 'frontier', blockedBy: [29], parentIssue: 24, frontier: true, readyForAgent: true }, + ] + try { + const host = document.createElement('div') + Object.defineProperty(host, 'clientWidth', { value: 475 }) + Object.defineProperty(host, 'clientHeight', { value: 952 }) + document.body.appendChild(host) + const sm = new StarMap() + sm.mount(host) + sm.setModel(tickets, {}, 16) + + const sample = (elapsed = 16) => { + drawn.length = 0 + arcs.length = 0 + vi.advanceTimersByTime(elapsed) + const cb = frames.pop() + frames = [] + cb?.(0) + const readyLabel = drawn.find((label) => label.text.startsWith('READY ยท 30')) + expect(readyLabel).toBeDefined() + + const readyWorld = sm.positions()[30] + const readyRing = arcs.find((arc) => + Math.abs(arc.worldRadius - (8.1 * 1.25 + 8)) < 1e-6 && + Math.hypot(arc.worldX - readyWorld.x, arc.worldY - readyWorld.y) < 4, + ) + expect(readyRing).toBeDefined() + + const fontSize = Number.parseFloat(readyLabel!.font) + const width = readyLabel!.text.length * 6 + const left = readyLabel!.align === 'left' + ? readyLabel!.x + : readyLabel!.align === 'right' + ? readyLabel!.x - width + : readyLabel!.x - width / 2 + const textBox = { + x0: left, + y0: readyLabel!.y - fontSize * 0.82, + x1: left + width, + y1: readyLabel!.y + fontSize * 0.22, + } + const nearestX = Math.max(textBox.x0, Math.min(readyRing!.x, textBox.x1)) + const nearestY = Math.max(textBox.y0, Math.min(readyRing!.y, textBox.y1)) + expect(Math.hypot(nearestX - readyRing!.x, nearestY - readyRing!.y)).toBeGreaterThanOrEqual( + readyRing!.radius + readyRing!.lineWidth / 2, + ) + return readyRing!.radius + } + + const parent = sm.positions()[24] + sm.restoreCamera({ + s: 1, + x: 237.5 - parent.x, + y: 476 - parent.y, + }) + sample() + + const canvas = host.querySelector('canvas')! + canvas.dispatchEvent(new WheelEvent('wheel', { + clientX: 237.5, + clientY: 476, + deltaY: 140, + })) + const easingRadii = Array.from({ length: 20 }, () => sample(100)) + expect(new Set(easingRadii.map((radius) => radius.toFixed(4))).size).toBeGreaterThan(5) + expect(easingRadii.at(-1)).toBeLessThan(easingRadii[0]) + } finally { + vi.useRealTimers() + } + }) + it('keeps labels centred for nodes in a parent cycle', () => { const { labels } = place([ { num: 6, slug: '6', title: 'Cycle A', type: 'task', status: 'open', frontier: false, blockedBy: [], parentIssue: 7 }, diff --git a/web/src/lib/starmap/starmap.ts b/web/src/lib/starmap/starmap.ts index 01585ce..30b41fe 100644 --- a/web/src/lib/starmap/starmap.ts +++ b/web/src/lib/starmap/starmap.ts @@ -1370,17 +1370,23 @@ export class StarMap { const core = this.#radius(n) let r = core + 2 if (n.sstate) r = core + 15 + if ( + n.parentIssue !== null && + this.#focus.readySet.has(n.num) && + this.#focus.current !== n.num + ) r = Math.max(r, core + 9) if (this.#focus.current === n.num) r = Math.max(r, core + 14) if (this.#selected === n.num) r = Math.max(r, core + 19) vis.push({ n, sx, sy, rad: r * s }) } - const obstacles: Box[] = vis.map((v) => ({ + const starObstacles = new Map(vis.map((v) => [v.n.num, { x0: v.sx - v.rad, y0: v.sy - v.rad, x1: v.sx + v.rad, y1: v.sy + v.rad, - })) + }])) + const obstacles: Box[] = [...starObstacles.values()] const order = [...vis].sort((a, b) => { const priority = (n: Node) => { @@ -1430,7 +1436,14 @@ export class StarMap { y1: worldGeometry.box.y1 * s + this.#cam.y, }, } - if (!obstacles.some((obstacle) => boxesOverlap(geometry.box, obstacle))) { + // The outward anchor already clears the visible glyphs from this star. + // Its padded reservation can graze the square star obstacle on diagonal + // spokes, which must not make the label disappear. Every other star and + // every previously placed label remain hard obstacles. + const ownStar = starObstacles.get(v.n.num) + if (!obstacles.some((obstacle) => + obstacle !== ownStar && boxesOverlap(geometry.box, obstacle), + )) { obstacles.push(geometry.box) items.push({ text,