Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -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
1 change: 1 addition & 0 deletions .github/workflows/linux-bundle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/macos-bundle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/windows-bundle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
95 changes: 95 additions & 0 deletions scripts/tests/npm-toolchain.tests.ps1
Original file line number Diff line number Diff line change
@@ -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)^ (?<name>[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"
7 changes: 7 additions & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
130 changes: 127 additions & 3 deletions web/src/lib/starmap/starmap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {
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<string, unknown>, 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<string, unknown>, text: string, x: number, y: number) {
drawn.push({
text,
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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 },
Expand Down
19 changes: 16 additions & 3 deletions web/src/lib/starmap/starmap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, Box>(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) => {
Expand Down Expand Up @@ -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,
Expand Down