diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b4a5d1..7a48a8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Clarified active-work priority with dimensional issue nodes, a maximum of two + status/selection rings, compact arrows, and literal `in-progress` label + support ahead of `ready-for-agent` and blocked work. - Emphasized the incoming and outgoing edges directly connected to a selected node while preserving dependency direction, state styling, and motion. - Restored dependency and parent relationship lines from cached Markdown issue diff --git a/docs/superpowers/plans/2026-08-06-node-visual-hierarchy-2-5d.md b/docs/superpowers/plans/2026-08-06-node-visual-hierarchy-2-5d.md new file mode 100644 index 0000000..0438853 --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-node-visual-hierarchy-2-5d.md @@ -0,0 +1,518 @@ +# Node Visual Hierarchy and 2.5D Rendering Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make in-progress work visually dominant, reduce ring and arrow clutter, and render stable 2.5D sphere nodes without changing graph topology or direct-only selection behavior. + +**Architecture:** Derive a narrow `WorkPriority` at the renderer adapter boundary, then let the Canvas 2D island use it for palette, metrics, rings, and label order. Keep depth entirely in paint layers and preserve PR #87's selected-last incident-edge pass while shrinking all arrowheads. + +**Tech Stack:** Svelte 5, TypeScript 6, Canvas 2D, Vitest 4, Rust/Tauri, native Windows PowerShell. + +## Global Constraints + +- Use native Windows executables only; do not use WSL or Linux tooling. +- Work only in `D:\tmp\stellr-selected-node-edge-emphasis` on `codex/node-visual-hierarchy-2-5d`; preserve the dirty primary checkout. +- Terminal issue states override stale workflow labels. +- Open priority is `in-progress`, assigned fallback, `ready-for-agent`, ordinary frontier, then blocked. +- The canonical ready label is exactly `ready-for-agent`; do not add a `ready` alias. +- Keep deterministic 2D coordinates, camera, historical topology, URLs, persistence, and server endpoints unchanged. +- Do not add WebGL, Three.js, Z coordinates, orbit controls, auto-rotation, or force simulation. +- Show at most one status ring plus one selection ring. +- Blocked, completed, out-of-scope, and ordinary frontier nodes have no outer status ring. +- All arrowheads use length `8` and half-width `4`; selected strokes retain the `1.7` multiplier. +- Selected emphasis stays limited to direct incoming and outgoing incident edges. +- Do not automatically relabel existing issues. + +--- + +### Task 1: Derive Workflow Priority at the Adapter Boundary + +**Files:** +- Create: `web/src/lib/starmap/work-priority.ts` +- Create: `web/src/lib/starmap/work-priority.test.ts` +- Modify: `web/src/lib/starmap/model.ts` +- Modify: `web/src/lib/starmap/adapt.ts` +- Modify: `web/src/lib/starmap/adapt.test.ts` + +**Interfaces:** +- Consumes: `Status`, issue labels, and assignees from `SpaceModel`. +- Produces: `WorkPriority = 'in_progress' | 'ready' | 'frontier' | 'blocked' | 'terminal'`. +- Produces: `deriveWorkPriority(input: WorkPriorityInput): WorkPriority`. +- Produces: `ticketWorkPriority(ticket): WorkPriority` for existing renderer fixtures. +- Produces: optional `Ticket.workPriority?: WorkPriority`; production adapter output always sets it. + +- [ ] **Step 1: Write the failing priority table** + +Create `work-priority.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { deriveWorkPriority } from './work-priority' + +describe('deriveWorkPriority', () => { + it.each([ + ['resolved overrides stale labels', 'resolved', ['in-progress'], ['ada'], 'terminal'], + ['out of scope overrides stale labels', 'out_of_scope', ['ready-for-agent'], [], 'terminal'], + ['literal in progress outranks a blocker', 'blocked', ['IN-PROGRESS'], [], 'in_progress'], + ['claimed is the assigned fallback', 'claimed', [], ['ada'], 'in_progress'], + ['an assignee is the assigned fallback', 'frontier', [], ['ada'], 'in_progress'], + ['ready requires an unblocked frontier', 'frontier', ['READY-FOR-AGENT'], [], 'ready'], + ['ready cannot override blocked', 'blocked', ['ready-for-agent'], [], 'blocked'], + ['unlabelled unblocked work stays frontier', 'frontier', [], [], 'frontier'], + ] as const)('%s', (_name, status, labels, assignees, expected) => { + expect(deriveWorkPriority({ status, labels, assignees })).toBe(expected) + }) +}) +``` + +- [ ] **Step 2: Run red** + +```powershell +C:\Users\pfdev\.vite-plus\bin\npm.exe --prefix web test -- src/lib/starmap/work-priority.test.ts +``` + +Expected: FAIL because `work-priority.ts` does not exist. + +- [ ] **Step 3: Implement the pure priority contract** + +Create `work-priority.ts`: + +```ts +import type { Status } from '../model' +import type { Ticket } from './model' + +export type WorkPriority = 'in_progress' | 'ready' | 'frontier' | 'blocked' | 'terminal' + +export interface WorkPriorityInput { + status: Status + labels: readonly string[] + assignees: readonly string[] +} + +const hasLabel = (labels: readonly string[], expected: string): boolean => + labels.some((label) => label.toLowerCase() === expected) + +export function deriveWorkPriority(input: WorkPriorityInput): WorkPriority { + if (input.status === 'resolved' || input.status === 'out_of_scope') return 'terminal' + if (hasLabel(input.labels, 'in-progress')) return 'in_progress' + if (input.status === 'claimed' || input.assignees.length > 0) return 'in_progress' + if (input.status === 'frontier' && hasLabel(input.labels, 'ready-for-agent')) return 'ready' + return input.status === 'blocked' ? 'blocked' : 'frontier' +} + +export function ticketWorkPriority( + ticket: Pick, +): WorkPriority { + if (ticket.workPriority) return ticket.workPriority + if (ticket.status === 'resolved' || ticket.status === 'out_of_scope') return 'terminal' + if (ticket.status === 'claimed') return 'in_progress' + if (ticket.readyForAgent) return 'ready' + return ticket.frontier ? 'frontier' : 'blocked' +} +``` + +Add a type-only `WorkPriority` import and `workPriority?: WorkPriority` to `Ticket`. In `toRendererModel`, derive priority once, assign it, and set `readyForAgent` only when priority is `ready`. + +- [ ] **Step 4: Extend adapter assertions** + +Make `adapt.test.ts` prove literal in-progress, assigned fallback, terminal override, truthful ready, and blocked-with-ready-label behavior: + +```ts +expect(model.map((ticket) => ticket.workPriority)).toEqual([ + 'in_progress', 'terminal', 'in_progress', 'terminal', 'blocked', +]) +expect(model.map((ticket) => ticket.readyForAgent)).toEqual([ + false, false, false, false, false, +]) +``` + +Add a separate unblocked `ready-for-agent` fixture and expect `workPriority: 'ready'` and `readyForAgent: true`. + +- [ ] **Step 5: Run green** + +```powershell +C:\Users\pfdev\.vite-plus\bin\npm.exe --prefix web test -- src/lib/starmap/work-priority.test.ts src/lib/starmap/adapt.test.ts +``` + +Expected: both files pass. + +- [ ] **Step 6: Commit** + +```powershell +git add -- web/src/lib/starmap/work-priority.ts web/src/lib/starmap/work-priority.test.ts web/src/lib/starmap/model.ts web/src/lib/starmap/adapt.ts web/src/lib/starmap/adapt.test.ts +git commit -m "feat(web): derive workflow visual priority" +``` + +--- + +### Task 2: Apply Priority Metrics, Palette, and Label Ordering + +**Files:** +- Modify: `web/src/lib/starmap/theme.ts` +- Create: `web/src/lib/starmap/theme.test.ts` +- Modify: `web/src/lib/starmap/starmap.ts` +- Modify: `web/src/lib/starmap/starmap.test.ts` + +**Interfaces:** +- Consumes: `WorkPriority` and `ticketWorkPriority` from Task 1. +- Produces: `priorityStarStyle(vstate: VisualState, priority: WorkPriority): StarStyle`. +- Produces: `priorityLabelColor(vstate: VisualState, priority: WorkPriority): string`. +- Produces: private renderer `Node.priority: WorkPriority`. +- Preserves: `visualState(ticket)` and every public layout/camera API. + +- [ ] **Step 1: Write failing metric tests** + +Create `theme.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { priorityLabelColor, priorityStarStyle } from './theme' + +describe('priorityStarStyle', () => { + it('orders active work without changing semantic palettes', () => { + expect(priorityStarStyle('blocked', 'in_progress')).toMatchObject({ core: '#ffd873', r: 8.1, gr: 42 }) + expect(priorityStarStyle('frontier', 'ready')).toMatchObject({ core: '#8ad8ff', r: 7.2, gr: 34 }) + expect(priorityStarStyle('frontier', 'frontier')).toMatchObject({ core: '#8ad8ff', r: 6.2, gr: 28 }) + expect(priorityStarStyle('blocked', 'blocked')).toMatchObject({ core: '#e2c3c3', r: 4.5, gr: 20 }) + expect(priorityStarStyle('resolved', 'terminal')).toMatchObject({ core: '#b9d6c4', r: 5.4, gr: 24 }) + expect(priorityStarStyle('out_of_scope', 'terminal')).toMatchObject({ core: '#948da4', r: 4.5, gr: 18 }) + expect(priorityLabelColor('blocked', 'in_progress')).toBe('#ffe6a0') + expect(priorityLabelColor('frontier', 'ready')).toBe('#b3e5ff') + }) +}) +``` + +- [ ] **Step 2: Run red** + +```powershell +C:\Users\pfdev\.vite-plus\bin\npm.exe --prefix web test -- src/lib/starmap/theme.test.ts +``` + +Expected: FAIL because `priorityStarStyle` is absent. + +- [ ] **Step 3: Implement exact metrics** + +Add to `theme.ts`: + +```ts +export function priorityStarStyle(vstate: VisualState, priority: WorkPriority): StarStyle { + if (priority === 'in_progress') return { ...STAR.claimed, r: 8.1, gr: 42 } + if (priority === 'ready') return { ...STAR.frontier, r: 7.2, gr: 34 } + if (priority === 'frontier') return { ...STAR.frontier, r: 6.2, gr: 28 } + return STAR[vstate] +} + +export function priorityLabelColor(vstate: VisualState, priority: WorkPriority): string { + if (priority === 'in_progress') return LABEL.claimed + if (priority === 'ready' || priority === 'frontier') return LABEL.frontier + return LABEL[vstate] +} +``` + +- [ ] **Step 4: Ingest priority without changing structure** + +Add `priority` to private `Node`. Set it with `ticketWorkPriority(t)` in both model-ingestion paths. Treat priority changes as paint/ticker changes, but do not add priority to `structureSignature`, layout input, or camera logic. Replace direct style reads in radius, glow, body, and label color with the priority-aware style. + +- [ ] **Step 5: Write the failing label-order test** + +Add a crowded `starmap.test.ts` fixture whose issue numbers oppose desired priority. Select issue `5`, make issue `6` the distinct CURRENT node, and assign in-progress `40`, ready `30`, frontier `20`, and blocked `10`. Assert retained label order: + +```ts +expect(labels.map((label) => label.text.slice(0, 2))).toEqual(['05', '06', '40', '30', '20', '10']) +``` + +- [ ] **Step 6: Implement label ordering** + +Keep selected first and a distinct CURRENT node second. Then order `in_progress`, `ready`, `frontier`, `blocked`, resolved, and out-of-scope. Use issue number only as the final same-priority tie-breaker. + +- [ ] **Step 7: Run green** + +```powershell +C:\Users\pfdev\.vite-plus\bin\npm.exe --prefix web test -- src/lib/starmap/theme.test.ts src/lib/starmap/starmap.test.ts +``` + +Expected: both files pass and deterministic layout assertions remain green. + +- [ ] **Step 8: Commit** + +```powershell +git add -- web/src/lib/starmap/theme.ts web/src/lib/starmap/theme.test.ts web/src/lib/starmap/starmap.ts web/src/lib/starmap/starmap.test.ts +git commit -m "feat(web): prioritize active work in the map" +``` + +--- + +### Task 3: Render 2.5D Spheres and the Two-Ring Grammar + +**Files:** +- Modify: `web/src/lib/starmap/starmap.ts` +- Modify: `web/src/lib/starmap/core-visual.test.ts` +- Modify: `web/src/lib/StarMap.svelte` +- Modify: `web/src/lib/StarMap.test.ts` + +**Interfaces:** +- Consumes: `Node.priority` and `priorityStarStyle` from Task 2. +- Produces: `StarMap.setReducedMotion(reduced: boolean): void`. +- Preserves: selection, session overlays, label text, and Canvas 2D ownership. + +- [ ] **Step 1: Write failing sphere-layer tests** + +Extend the `core-visual.test.ts` recorder so gradients retain origin/edge coordinates and arcs retain center/radius. Assert paint order is semantic glow, offset contact shadow, semantic body gradient, small above-left specular gradient, then one internal boundary. + +For a ready body, assert exact body stops: + +```ts +expect(bodyGradient.stops).toEqual([ + { at: 0, color: 'rgba(138,216,255,1)' }, + { at: 0.48, color: 'rgba(138,216,255,0.98)' }, + { at: 0.82, color: 'rgba(47,155,224,0.92)' }, + { at: 1, color: 'rgba(47,155,224,0.62)' }, +]) +expect(shadow.arc!.y).toBeGreaterThan(body.arc!.y) +expect(specular.arc!.radius).toBeLessThan(body.arc!.radius / 3) +``` + +- [ ] **Step 2: Write failing ring-count tests** + +Add an `outerRings` helper that excludes the internal boundary by radius. Assert: + +```ts +expect(outerRings(paint(IN_PROGRESS))).toHaveLength(1) +expect(outerRings(paint(READY_CHILD))).toHaveLength(1) +expect(outerRings(paint(BLOCKED))).toHaveLength(0) +expect(outerRings(paint(RESOLVED))).toHaveLength(0) +expect(outerRings(paint({ ...BLOCKED, parentIssue: 99 }))).toHaveLength(0) +expect(outerRings(paint(READY_CHILD, null, READY_CHILD.num))).toHaveLength(2) +expect(outerRings(paint(BLOCKED, null, BLOCKED.num))).toHaveLength(1) +``` + +Extend `paint` with a `selectedIssue` argument that calls `map.select` after +`setModel`. Record a selected click frame and assert no expanding flare stroke +is painted. Keep separate coverage proving a distinct CURRENT node receives no +outer CURRENT ring and retains its label/path semantics. + +- [ ] **Step 3: Run red** + +```powershell +C:\Users\pfdev\.vite-plus\bin\npm.exe --prefix web test -- src/lib/starmap/core-visual.test.ts +``` + +Expected: FAIL against the black disk, subissue rim, paired claimed/CURRENT rings, and ring flare. + +- [ ] **Step 4: Implement sphere layers** + +Calculate `priorityStarStyle` once. Keep the semantic glow, then paint: + +- contact shadow centered at `y + cr * 0.72`, radius `cr * 1.08`, black alpha `0.26` to `0`; +- body gradient with an above-left origin and the exact stops from Step 1; +- specular centered at `x - cr * 0.32`, `y - cr * 0.34`, radius `cr * 0.28`, white alpha `0.46` to `0`; +- one internal boundary at `cr - lineWidth / 2`. + +Use `c.core`, `c.glow`, and `hexA` for the body stops: core at `1`, core at +`0.98`, glow at `0.92`, and glow at `0.62`. Do not add a new semantic hue. + +- [ ] **Step 5: Implement the ring grammar** + +Delete `SUBISSUE_RIM`, the subissue rim, both CURRENT strokes, and the flare stroke. Paint one breathing amber ring at `cr + 7 + beat` for `in_progress`, one steady cyan ring at `cr + 7` for `ready`, and one white selection ring at `cr + 13` when a status ring exists or `cr + 7` otherwise. Keep `flare` only as a short semantic-glow multiplier. + +- [ ] **Step 6: Add reduced-motion wiring** + +Add private `#reducedMotion = false` and: + +```ts +setReducedMotion(reduced: boolean): void { + this.#reducedMotion = reduced +} +``` + +Use midpoint beat `0.5` when reduced motion is true. Continue decrementing the +private flare timer, but draw it as `fl > 0 ? 1 : 0` under reduced motion so the +glow remains visually static and then clears instead of interpolating. Add two +recorded reduced-motion frames while the timer is positive and assert identical +glow geometry/alpha. In `StarMap.svelte`, subscribe to +`matchMedia('(prefers-reduced-motion: reduce)')`, set the initial value, forward +changes, and remove the listener during cleanup. In `StarMap.test.ts`, assert +initial and changed values reach the same renderer instance. + +- [ ] **Step 7: Run green** + +```powershell +C:\Users\pfdev\.vite-plus\bin\npm.exe --prefix web test -- src/lib/starmap/core-visual.test.ts src/lib/StarMap.test.ts src/lib/starmap/starmap.test.ts +``` + +Expected: all files pass with maximum-two-ring and reduced-motion coverage. + +- [ ] **Step 8: Commit** + +```powershell +git add -- web/src/lib/starmap/starmap.ts web/src/lib/starmap/core-visual.test.ts web/src/lib/StarMap.svelte web/src/lib/StarMap.test.ts +git commit -m "feat(web): render dimensional issue nodes" +``` + +--- + +### Task 4: Shrink Arrowheads Without Weakening Direct Selection + +**Files:** +- Modify: `web/src/lib/starmap/starmap.ts` +- Modify: `web/src/lib/starmap/edge-visual.test.ts` + +**Interfaces:** +- Consumes: the existing selected-edge incident predicate and selected-last pass. +- Produces: shared arrow geometry with length `8` and half-width `4`. +- Preserves: selected stroke scale `1.7`, semantic styling, motion, full selected opacity, and direct-only order. + +- [ ] **Step 1: Change arrow expectations first** + +Update every ordinary, selected dependency, and selected parent/subissue assertion: + +```ts +expectArrowDimensions(arrow, 8, 4) +``` + +Keep exact selected/non-selected stroke-order assertions and explicitly compare selected and context arrow dimensions for equality. + +- [ ] **Step 2: Run red** + +```powershell +C:\Users\pfdev\.vite-plus\bin\npm.exe --prefix web test -- src/lib/starmap/edge-visual.test.ts +``` + +Expected: FAIL because current ordinary arrows are `12` by `6.5` and selected arrows are `15` by `8.125`. + +- [ ] **Step 3: Implement compact shared geometry** + +Delete `SELECTED_EDGE_ARROW_SCALE` and use: + +```ts +const ah = 8 +const aw = 4 +``` + +Do not change stroke scaling, paint order, alpha, color, dashes, curves, tangent direction, particles, or `#isSelectedEdge`. + +- [ ] **Step 4: Run edge and node regressions** + +```powershell +C:\Users\pfdev\.vite-plus\bin\npm.exe --prefix web test -- src/lib/starmap/edge-visual.test.ts src/lib/starmap/core-visual.test.ts +``` + +Expected: both files pass; direct-only selection and maximum-two-ring assertions stay green. + +- [ ] **Step 5: Commit** + +```powershell +git add -- web/src/lib/starmap/starmap.ts web/src/lib/starmap/edge-visual.test.ts +git commit -m "feat(web): reduce graph arrowheads" +``` + +--- + +### Task 5: Document, Validate, and Add the Tracker Label + +**Files:** +- Modify: `CHANGELOG.md` +- Verify: every file changed since `origin/main` +- External mutation: create the approved `in-progress` label in `teloverge/stellr` + +**Interfaces:** +- Consumes: Tasks 1-4. +- Produces: newest-first Unreleased notes and the approved tracker label. +- Produces no issue relabeling, push, PR, merge, release, or installer. + +- [ ] **Step 1: Add the newest Unreleased entry** + +Insert directly under `## Unreleased`: + +```markdown +- Clarified active-work priority with dimensional issue nodes, a maximum of two + status/selection rings, compact arrows, and literal `in-progress` label + support ahead of `ready-for-agent` and blocked work. +``` + +- [ ] **Step 2: Run frontend verification** + +```powershell +C:\Users\pfdev\.vite-plus\bin\npm.exe --prefix web test +C:\Users\pfdev\.vite-plus\bin\npm.exe --prefix web run check +C:\Users\pfdev\.vite-plus\bin\npm.exe --prefix web run build +``` + +Expected: all Vitest files pass, Svelte reports `0 errors and 0 warnings`, and Vite exits `0`. + +- [ ] **Step 3: Run native workspace verification** + +```powershell +cargo.exe fmt --all -- --check +cargo.exe clippy --workspace --all-targets --locked -- -D warnings +cargo.exe test --workspace --locked -- --test-threads=1 +``` + +Expected: all commands exit `0`; only explicitly ignored tests remain unrun. + +- [ ] **Step 4: Verify scope and generated-file cleanliness** + +```powershell +git diff --check origin/main...HEAD +git status --short --branch +git diff --stat origin/main...HEAD +``` + +If Tauri changes only the nine known `crates/app/permissions/autogenerated/*.toml` files, first prove `git diff --ignore-space-at-eol --exit-code` for those exact paths, then restore only those files. Never restore a semantic diff. + +- [ ] **Step 5: Perform native visual verification** + +Build and launch the updated native desktop binary from the isolated worktree: + +```powershell +cargo.exe build --package stellr-app --release --bin stellr-desktop +Start-Process -FilePath target\release\stellr-desktop.exe -WorkingDirectory target\release +``` + +Inspect the live map at ordinary and selected zoom levels. Confirm an assigned +or `in-progress` issue is the strongest sphere with one amber ring, a +`ready-for-agent` issue has one quieter cyan ring, blocked and completed nodes +have no outer status ring, selection adds exactly one white ring, nodes retain +their positions, and arrowheads remain readable at the compact size. Capture +the selected-ready and selected-in-progress states when live data provides +them; otherwise use the focused canvas recordings as the deterministic evidence +for those combinations and state that limitation explicitly. + +- [ ] **Step 6: Commit changelog after gates pass** + +```powershell +git add -- CHANGELOG.md +git commit -m "docs: record node visual hierarchy" +``` + +- [ ] **Step 7: Create and verify the approved tracker label** + +Recheck that it is absent: + +```powershell +gh label list --repo teloverge/stellr --limit 100 --json name --jq ".[].name" +``` + +If absent, create exactly: + +```powershell +gh label create in-progress --repo teloverge/stellr --color fbca04 --description "Work is actively being implemented" +``` + +Verify without applying it to any issue: + +```powershell +gh label list --repo teloverge/stellr --search in-progress --json name,color,description +``` + +Expected: one `in-progress` label, color `fbca04`, with the approved description. + +- [ ] **Step 8: Run final committed-state verification** + +```powershell +C:\Users\pfdev\.vite-plus\bin\npm.exe --prefix web test +git status --short --branch +git log --oneline origin/main..HEAD +``` + +Expected: frontend tests pass, the worktree is clean, and the branch contains only the design, plan, implementation, and changelog commits. diff --git a/docs/superpowers/specs/2026-08-06-node-visual-hierarchy-2-5d-design.md b/docs/superpowers/specs/2026-08-06-node-visual-hierarchy-2-5d-design.md new file mode 100644 index 0000000..5a1b5f5 --- /dev/null +++ b/docs/superpowers/specs/2026-08-06-node-visual-hierarchy-2-5d-design.md @@ -0,0 +1,204 @@ +# Node Visual Hierarchy and 2.5D Rendering Design + +## Goal + +Make the map's work priority readable at a glance, reduce ring and arrowhead +clutter, and give nodes an Obsidian-inspired three-dimensional presence without +replacing Stellr's deterministic two-dimensional topology or camera. + +The visual priority for active work is: + +1. `in-progress`; +2. `ready-for-agent`; +3. blocked. + +Closed and not-planned issues remain terminal states rather than active work. + +## Status Resolution + +Status derivation continues to resolve terminal issue state first. A closed +issue is completed and a closed-not-planned issue is out of scope even if stale +workflow labels remain on GitHub. + +For an open issue, resolve the visible priority in this order: + +1. a case-insensitive `in-progress` label; +2. the existing claimed fallback when the issue has one or more assignees; +3. the canonical case-insensitive `ready-for-agent` label when no blocker is + open; +4. blocked when at least one blocker remains open; +5. the existing unblocked frontier treatment without a READY marker. + +An open issue labeled `in-progress` therefore retains the strongest active +treatment even when it has an unresolved blocker. `ready-for-agent` remains +truthful only for unblocked work. Do not introduce a `ready` alias: the +repository's canonical tracker vocabulary remains `ready-for-agent`. + +Add the missing `in-progress` tracker label with color `#fbca04` and description +`Work is actively being implemented`. Do not automatically add or remove it on +existing issues; label application remains an explicit tracker action. + +## Reference Treatment + +Obsidian's core Graph View establishes the useful interaction vocabulary: +circles are nodes, lines are relationships, and focusing a node highlights its +connections. Community 3D graph views add lit sphere meshes, perspective, +orbiting, and neighbor emphasis. Stellr adopts only the lit-sphere depth cue and +neighbor emphasis from that treatment. It deliberately retains its fixed 2D +map, camera, and direct-edge behavior. + +References: + +- +- + +## Node Rendering + +Keep the current Canvas 2D renderer and deterministic node coordinates. Render +each node as a 2.5D sphere using layered radial gradients: + +- a small, soft specular highlight above and left of center; +- the semantic status color across the middle of the sphere; +- a darker lower-right falloff that supplies volume; +- a restrained contact shadow below the sphere; +- one thin internal boundary stroke that is part of the body, not an outer + status ring. + +The depth treatment must preserve the existing status palette. It may derive +lighter and darker variants from the semantic color, but it must not introduce +a new color meaning. The highlight and shadow stay fixed in screen orientation +so nodes read as consistently lit while the map pans and zooms. + +This is visual depth, not graph depth. Do not add Z coordinates, perspective +layout, orbit controls, auto-rotation, WebGL, Three.js, force simulation, or +status-dependent node movement. Historical replay must keep the same spatial +topology. + +## Ring Grammar + +An outer ring has exactly one meaning. Stable rendering may show at most one +status ring and one selection ring around a node. + +- `in-progress`: one amber breathing status ring. This is the strongest active + treatment. Replace the current pair of claimed rings with this single ring. +- `ready-for-agent`: one quieter cyan status ring with no breathing motion. +- blocked, completed, out-of-scope, and ordinary unblocked frontier: no outer + status ring; render only the 2.5D node body. +- selected: one neutral white selection ring outside the status ring when one + exists, or outside the node body otherwise. + +Remove the node-level parent/subissue rim. Parent and subissue meaning already +travels through the workflow edges and must not consume another ring channel. +Remove the two CURRENT rings because CURRENT already has a label and path +emphasis, while selection alone owns the neutral white ring. CURRENT and +selection may name different nodes; removing CURRENT's rings must not merge or +otherwise change those states. Replace the ring-shaped click flare with a brief +increase in sphere glow so interaction feedback cannot create a third outer +ring. + +The maximum stable count is therefore two outer rings for a selected +in-progress or ready issue, one for an unselected in-progress or ready issue, +one for a selected blocked or completed issue, and zero for an unselected +blocked or completed issue. + +## Relative Emphasis and Labels + +The base node sizing, glow strength, and label-slot priority must follow the +same active-work order. Use these body-radius and glow-radius pairs before the +existing issue-radius scale is applied: + +- in-progress: `8.1` and `42`; +- ready-for-agent: `7.2` and `34`; +- ordinary unblocked frontier: `6.2` and `28`; +- blocked: `4.5` and `20`; +- completed: retain `5.4` and `24`; +- out-of-scope: retain `4.5` and `18`. + +Selection remains the first label-placement priority because it reflects the +operator's immediate action. A distinct CURRENT node remains second because it +anchors the active session. After those two interaction states, an in-progress +issue gets a contested label slot before a ready issue, a ready issue gets it +before an ordinary frontier issue, and an ordinary frontier issue gets it +before a blocked issue. Completed and out-of-scope nodes retain their existing +relative label priority after active work. + +Keep the existing selected label and detail-pane behavior. A selected ready +issue may still say `CURRENT / READY`; this design changes the surrounding +visual clutter, not the label vocabulary. + +## Edges and Arrowheads + +Preserve every edge's semantic color, dash pattern, direction, particle motion, +direct-only selection membership, and selected-last paint order from PR #87. + +Reduce ordinary arrowhead length from `12` to `8` canvas units and half-width +from `6.5` to `4`. Selected edges use the same compact arrowhead dimensions; +selection continues to read through full opacity, painter order, and the +existing `1.7` stroke-width multiplier. Remove the selected arrow-size +multiplier so selection does not make arrowheads busy again. + +Do not change edge paths, curve geometry, particle size, topology, or the rule +that only edges directly incident to the selected node receive selected +emphasis. + +## Renderer and Model Boundaries + +Keep sphere paint, ring paint, click glow, and arrowhead dimensions inside the +imperative canvas renderer. Isolate workflow-label precedence in a small, +purely testable derivation function rather than scattering label checks through +paint code. + +The pushed model already carries labels and assignees, but the renderer adapter +currently retains only `readyForAgent`. Extend the narrow renderer ticket model +with the minimum derived workflow flags needed by the renderer. Do not pass the +entire label array into the canvas island or change layout signatures, camera +state, URLs, persistence, server endpoints, or historical event storage. + +## Accessibility and Motion + +Color is not the only signal: + +- in-progress has a breathing ring and stronger size/glow; +- ready has one steady ring; +- blocked and terminal nodes have no outer status ring; +- selection has a neutral outer ring and direct-edge emphasis. + +Under reduced motion, freeze the in-progress ring at its midpoint and keep the +click response as a short static glow change. The sphere highlight and shadow +remain visible without animation. + +## Verification + +Add or update tests that prove: + +- terminal states override stale `in-progress` and `ready-for-agent` labels; +- open-state precedence is in-progress, then assigned fallback, then ready, + then blocked/frontier; +- a blocked issue cannot become ready solely because it has the ready label; +- in-progress and ready nodes have exactly one status ring; +- blocked and completed nodes have no outer status ring; +- selection adds exactly one ring and never produces more than two stable outer + rings; +- parent/subissue membership adds no node ring; +- click feedback changes glow without adding a ring; +- sphere painting records the highlight, semantic body, shade, shadow, and body + boundary in the intended order; +- label priority orders selected first, a distinct CURRENT node second, then + in-progress, ready, ordinary frontier, and blocked; +- ordinary and selected arrowheads both use length `8` and half-width `4`; +- selected edges retain `1.7` stroke scaling, full-opacity selected-last paint, + semantic styling, and direct-only scope; +- reduced motion freezes rather than removes the in-progress status signal. + +The completion gate is the focused derivation and canvas tests, complete +frontend tests, Svelte check, production frontend build, Rust formatting, +workspace Clippy with warnings denied, and locked native Windows workspace +tests. Visual verification must cover an in-progress node, ready node, blocked +node, completed node, selected ready node, and selected in-progress node. + +## Non-Goals + +This change does not implement a true three-dimensional graph, migrate to a +different rendering library, create or infer tracker assignments, relabel +existing issues, change dependency semantics, highlight transitive paths, +change timeline topology, or redesign the detail pane. diff --git a/web/src/lib/StarMap.svelte b/web/src/lib/StarMap.svelte index 474dfa1..b5f419b 100644 --- a/web/src/lib/StarMap.svelte +++ b/web/src/lib/StarMap.svelte @@ -43,6 +43,14 @@ onMount(() => { renderer = new Renderer() + const motionQuery = typeof matchMedia === 'function' + ? matchMedia('(prefers-reduced-motion: reduce)') + : null + const updateReducedMotion = (event: MediaQueryListEvent) => { + renderer?.setReducedMotion(event.matches) + } + renderer.setReducedMotion(motionQuery?.matches ?? false) + motionQuery?.addEventListener('change', updateReducedMotion) const background = getComputedStyle(host).getPropertyValue('--map-background').trim() renderer.setBackground(background) renderer.mount(host) @@ -51,6 +59,7 @@ }) return () => { + motionQuery?.removeEventListener('change', updateReducedMotion) renderer?.destroy() renderer = undefined } diff --git a/web/src/lib/StarMap.test.ts b/web/src/lib/StarMap.test.ts index 8b17a27..b914656 100644 --- a/web/src/lib/StarMap.test.ts +++ b/web/src/lib/StarMap.test.ts @@ -19,6 +19,7 @@ afterEach(async () => { document.head.querySelectorAll('[data-test-app-css]').forEach((style) => style.remove()) document.documentElement.style.removeProperty('--map-background') vi.restoreAllMocks() + vi.unstubAllGlobals() }) function space(number: number): SpaceModel { @@ -80,6 +81,37 @@ describe('StarMap wrapper', () => { expect(setBackground).toHaveBeenCalledWith('rgb(12, 34, 56)') }) + it('forwards reduced-motion changes to one renderer and removes its listener', async () => { + let change: ((event: MediaQueryListEvent) => void) | undefined + const removeEventListener = vi.fn() + vi.stubGlobal('matchMedia', vi.fn(() => ({ + matches: true, + media: '(prefers-reduced-motion: reduce)', + onchange: null, + addEventListener: (_type: string, listener: (event: MediaQueryListEvent) => void) => { + change = listener + }, + removeEventListener, + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + }))) + const setReducedMotion = vi.spyOn(Renderer.prototype, 'setReducedMotion') + const target = document.createElement('div') + document.body.appendChild(target) + + const component = mount(StarMap, { target, props: { space: space(42) } }) + mounted.push(component) + flushSync() + change?.({ matches: false } as MediaQueryListEvent) + + expect(setReducedMotion.mock.calls).toEqual([[true], [false]]) + expect(new Set(setReducedMotion.mock.instances).size).toBe(1) + await unmount(component) + mounted.splice(mounted.indexOf(component), 1) + expect(removeEventListener).toHaveBeenCalledWith('change', change) + }) + it('feeds reactive space prop updates to the renderer', () => { const setModel = vi.spyOn(Renderer.prototype, 'setModel') const target = document.createElement('div') diff --git a/web/src/lib/starmap/adapt.test.ts b/web/src/lib/starmap/adapt.test.ts index 54b2eb2..4108c0c 100644 --- a/web/src/lib/starmap/adapt.test.ts +++ b/web/src/lib/starmap/adapt.test.ts @@ -35,6 +35,11 @@ describe('toRendererModel', () => { it('maps every issue status and directs blocker edges toward the blocked issue', () => { const input = space() input.stars[0].parent_issue = 16 + input.stars[0].labels = ['IN-PROGRESS'] + input.stars[1].labels = ['in-progress'] + input.stars[1].assignees = ['ada'] + input.stars[3].labels = ['ready-for-agent'] + input.stars[4].labels = ['ready-for-agent'] const model = toRendererModel(input) @@ -49,6 +54,20 @@ describe('toRendererModel', () => { }) expect(model[1].parentIssue).toBeNull() expect(edgesOf(model)).toContainEqual({ from: 1, to: 5 }) + expect(model.map((ticket) => ticket.workPriority)).toEqual([ + 'in_progress', + 'terminal', + 'in_progress', + 'terminal', + 'blocked', + ]) + expect(model.map((ticket) => ticket.readyForAgent)).toEqual([ + false, + false, + false, + false, + false, + ]) }) it('marks only an unblocked ready-for-agent issue as actionable', () => { @@ -66,5 +85,12 @@ describe('toRendererModel', () => { false, false, ]) + expect(model.map((ticket) => ticket.workPriority)).toEqual([ + 'ready', + 'frontier', + 'in_progress', + 'terminal', + 'blocked', + ]) }) }) diff --git a/web/src/lib/starmap/adapt.ts b/web/src/lib/starmap/adapt.ts index f8b3c05..26ddbe1 100644 --- a/web/src/lib/starmap/adapt.ts +++ b/web/src/lib/starmap/adapt.ts @@ -1,18 +1,21 @@ import type { SpaceModel } from '../model' import type { Ticket } from './model' +import { deriveWorkPriority } from './work-priority' export function toRendererModel(space: SpaceModel): Ticket[] { - return space.stars.map((star) => ({ - num: star.number, - slug: String(star.number), - title: star.title, - type: 'issue', - status: star.status, - blockedBy: [...star.blocked_by], - parentIssue: star.parent_issue, - frontier: star.status === 'frontier', - readyForAgent: - star.status === 'frontier' && - star.labels.some((label) => label.toLowerCase() === 'ready-for-agent'), - })) + return space.stars.map((star) => { + const workPriority = deriveWorkPriority(star) + return { + num: star.number, + slug: String(star.number), + title: star.title, + type: 'issue', + status: star.status, + blockedBy: [...star.blocked_by], + parentIssue: star.parent_issue, + frontier: star.status === 'frontier', + readyForAgent: workPriority === 'ready', + workPriority, + } + }) } diff --git a/web/src/lib/starmap/core-visual.test.ts b/web/src/lib/starmap/core-visual.test.ts index cee1556..85478cd 100644 --- a/web/src/lib/starmap/core-visual.test.ts +++ b/web/src/lib/starmap/core-visual.test.ts @@ -2,121 +2,85 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { StarMap } from './starmap' import type { Ticket } from './model' -type Arc = { radius: number } -type Gradient = { kind: 'radial-gradient'; stops: Array<{ at: number; color: string }> } -type Fill = { style: string | Gradient; arc: Arc | undefined } -type Stroke = { style: string | Gradient; lineWidth: number; arc: Arc | undefined } +type Arc = { x: number; y: number; radius: number } +type Gradient = { + kind: 'radial-gradient' + origin: { x0: number; y0: number; r0: number; x1: number; y1: number; r1: number } + stops: Array<{ at: number; color: string }> +} +type Fill = { kind: 'fill'; style: string | Gradient; arc: Arc | undefined } +type Stroke = { kind: 'stroke'; style: string | Gradient; lineWidth: number; arc: Arc | undefined } +type Paint = Fill | Stroke const RESOLVED: Ticket = { - num: 1, - slug: '1', - title: 'Resolved core', - type: 'issue', - status: 'resolved', - frontier: false, - blockedBy: [], - parentIssue: null, + num: 1, slug: '1', title: 'Resolved', type: 'issue', status: 'resolved', + frontier: false, blockedBy: [], parentIssue: null, workPriority: 'terminal', } -const FRONTIER: Ticket = { - num: 2, - slug: '2', - title: 'Frontier core', - type: 'issue', - status: 'open', - frontier: true, - blockedBy: [], - parentIssue: null, +const IN_PROGRESS: Ticket = { + num: 2, slug: '2', title: 'In progress', type: 'issue', status: 'open', + frontier: false, blockedBy: [], parentIssue: null, workPriority: 'in_progress', } -const CLAIMED: Ticket = { - num: 3, - slug: '3', - title: 'Claimed core', - type: 'issue', - status: 'claimed', - frontier: false, - blockedBy: [], - parentIssue: null, +const READY: Ticket = { + num: 3, slug: '3', title: 'Ready', type: 'issue', status: 'open', + frontier: true, blockedBy: [], parentIssue: null, readyForAgent: true, workPriority: 'ready', } const BLOCKED: Ticket = { - num: 4, - slug: '4', - title: 'Blocked core', - type: 'issue', - status: 'open', - frontier: false, - blockedBy: [1], - parentIssue: null, -} -const OUT_OF_SCOPE: Ticket = { - num: 5, - slug: '5', - title: 'Out of scope core', - type: 'issue', - status: 'out_of_scope', - frontier: false, - blockedBy: [], - parentIssue: null, -} -const INCOMPLETE_CHILD: Ticket = { ...BLOCKED, num: 6, slug: '6', parentIssue: 99 } -const RESOLVED_CHILD: Ticket = { ...RESOLVED, num: 7, slug: '7', parentIssue: 99 } -const OUT_OF_SCOPE_CHILD: Ticket = { ...OUT_OF_SCOPE, num: 9, slug: '9', parentIssue: 99 } -const READY_CHILD: Ticket = { - ...FRONTIER, - num: 8, - slug: '8', - parentIssue: 99, - readyForAgent: true, + num: 4, slug: '4', title: 'Blocked', type: 'issue', status: 'open', + frontier: false, blockedBy: [], parentIssue: null, workPriority: 'blocked', } +const READY_CHILD: Ticket = { ...READY, num: 5, slug: '5', parentIssue: 99 } -function recordingContext(): { - ctx: Record - fills: Fill[] - strokes: Stroke[] -} { - const fills: Fill[] = [] - const strokes: Stroke[] = [] +function recordingContext() { + const paints: Paint[] = [] + const texts: string[] = [] let arc: Arc | undefined const ctx: Record = { - createRadialGradient: () => { - const gradient: Gradient = { kind: 'radial-gradient', stops: [] } + createRadialGradient: ( + x0: number, y0: number, r0: number, x1: number, y1: number, r1: number, + ) => { + const gradient: Gradient = { + kind: 'radial-gradient', + origin: { x0, y0, r0, x1, y1, r1 }, + stops: [], + } Object.defineProperty(gradient, 'addColorStop', { value: (at: number, color: string) => gradient.stops.push({ at, color }), }) return gradient }, - beginPath: () => { - arc = undefined - }, - arc: (_x: number, _y: number, radius: number) => { - arc = { radius } - }, - fill: () => fills.push({ style: ctx.fillStyle as string | Gradient, arc }), - stroke: () => - strokes.push({ - style: ctx.strokeStyle as string | Gradient, - lineWidth: ctx.lineWidth as number, - arc, - }), + beginPath: () => { arc = undefined }, + arc: (x: number, y: number, radius: number) => { arc = { x, y, radius } }, + fill: () => paints.push({ kind: 'fill', style: ctx.fillStyle as string | Gradient, arc }), + stroke: () => paints.push({ + kind: 'stroke', + style: ctx.strokeStyle as string | Gradient, + lineWidth: ctx.lineWidth as number, + arc, + }), measureText: () => ({ width: 40 }), fillRect: () => {}, - fillText: () => {}, + fillText: (text: string) => texts.push(text), } for (const method of [ - 'setTransform', - 'moveTo', - 'lineTo', - 'closePath', - 'quadraticCurveTo', - 'setLineDash', - 'save', - 'restore', - 'translate', - 'scale', - 'rotate', - ]) { - ctx[method] = () => {} - } - return { ctx, fills, strokes } + 'setTransform', 'moveTo', 'lineTo', 'closePath', 'quadraticCurveTo', 'setLineDash', + 'save', 'restore', 'translate', 'scale', 'rotate', + ]) ctx[method] = () => {} + return { ctx, paints, texts } +} + +function bodyFill(paints: Paint[]): Fill { + return paints.find((paint): paint is Fill => + paint.kind === 'fill' && + typeof paint.style !== 'string' && + paint.style.stops.some((stop) => stop.at === 0.48), + )! +} + +function outerRings(paints: Paint[]): Stroke[] { + const body = bodyFill(paints) + return paints.filter((paint): paint is Stroke => + paint.kind === 'stroke' && (paint.arc?.radius ?? 0) > body.arc!.radius, + ) } describe('issue core visual grammar', () => { @@ -142,7 +106,11 @@ describe('issue core visual grammar', () => { document.body.replaceChildren() }) - function paint(ticket: Ticket, currentIssue: number | null = null): { fills: Fill[]; strokes: Stroke[] } { + function paint( + ticket: Ticket, + currentIssue: number | null = null, + selectedIssue: number | null = null, + ) { const recording = recordingContext() getContext.mockReturnValue(recording.ctx as never) const host = document.createElement('div') @@ -152,6 +120,7 @@ describe('issue core visual grammar', () => { const map = new StarMap() map.mount(host) map.setModel([ticket], {}, currentIssue) + if (selectedIssue !== null) map.select(selectedIssue) const frame = frames.pop() frames = [] frame?.(0) @@ -159,145 +128,98 @@ describe('issue core visual grammar', () => { return recording } - it('keeps resolved as the existing solid radial-gradient core without a black disk', () => { - const { fills } = paint(RESOLVED) - - expect(fills).toHaveLength(2) - expect(fills[1]).toEqual({ - style: { - kind: 'radial-gradient', - stops: [ - { at: 0, color: 'rgba(185,214,196,1)' }, - { at: 0.6, color: 'rgba(185,214,196,0.92)' }, - { at: 0.82, color: 'rgba(185,214,196,0.45)' }, - { at: 1, color: 'rgba(185,214,196,0)' }, - ], - }, - arc: { radius: 9.1125 }, - }) - expect(fills.some((fill) => fill.style === '#000')).toBe(false) + it('layers glow, contact shadow, body, specular, and an internal boundary', () => { + const { paints } = paint(READY) + const body = bodyFill(paints) + const bodyIndex = paints.indexOf(body) + const glow = paints[0] as Fill + const shadow = paints[1] as Fill + const specular = paints[bodyIndex + 1] as Fill + const boundary = paints[bodyIndex + 2] as Stroke + + expect([glow.kind, shadow.kind, body.kind, specular.kind, boundary.kind]).toEqual([ + 'fill', 'fill', 'fill', 'fill', 'stroke', + ]) + expect((body.style as Gradient).stops).toEqual([ + { at: 0, color: 'rgba(138,216,255,1)' }, + { at: 0.48, color: 'rgba(138,216,255,0.98)' }, + { at: 0.82, color: 'rgba(47,155,224,0.92)' }, + { at: 1, color: 'rgba(47,155,224,0.62)' }, + ]) + expect((body.style as Gradient).origin.x0).toBeLessThan(body.arc!.x) + expect((body.style as Gradient).origin.y0).toBeLessThan(body.arc!.y) + expect(shadow.arc!.y).toBeGreaterThan(body.arc!.y) + expect(specular.arc!.x).toBeLessThan(body.arc!.x) + expect(specular.arc!.y).toBeLessThan(body.arc!.y) + expect(specular.arc!.radius).toBeLessThan(body.arc!.radius / 3) + expect(boundary.arc!.radius).toBeLessThan(body.arc!.radius) }) - it('paints every incomplete state with its unchanged glow, black disk, and status rim', () => { - for (const expected of [ - { - ticket: FRONTIER, - glow: [ - { at: 0, color: 'rgba(47,155,224,0.765)' }, - { at: 0.4, color: 'rgba(47,155,224,0.198)' }, - { at: 1, color: 'rgba(47,155,224,0)' }, - ], - blackRadius: 10.125, - rim: 'rgba(138,216,255,0.95)', - width: 3.24, - }, - { - ticket: CLAIMED, - glow: [ - { at: 0, color: 'rgba(255,176,32,0.85)' }, - { at: 0.4, color: 'rgba(255,176,32,0.22)' }, - { at: 1, color: 'rgba(255,176,32,0)' }, - ], - blackRadius: 9, - rim: 'rgba(255,216,115,0.95)', - width: 2.88, - }, - { - ticket: BLOCKED, - glow: [ - { at: 0, color: 'rgba(154,111,111,0.85)' }, - { at: 0.4, color: 'rgba(154,111,111,0.22)' }, - { at: 1, color: 'rgba(154,111,111,0)' }, - ], - blackRadius: 5.625, - rim: 'rgba(226,195,195,0.95)', - width: 2.2, - }, - { - ticket: OUT_OF_SCOPE, - glow: [ - { at: 0, color: 'rgba(107,100,120,0.85)' }, - { at: 0.4, color: 'rgba(107,100,120,0.22)' }, - { at: 1, color: 'rgba(107,100,120,0)' }, - ], - blackRadius: 5.625, - rim: 'rgba(148,141,164,0.95)', - width: 2.2, - }, - ]) { - const { fills, strokes } = paint(expected.ticket) - const glow = fills[0].style as Gradient - - expect(fills).toHaveLength(2) - expect(glow.kind).toBe('radial-gradient') - expect(glow.stops).toEqual(expected.glow) - expect(fills[1]).toEqual({ style: '#000', arc: { radius: expected.blackRadius } }) - expect(fills.filter((fill) => fill.style === '#000')).toHaveLength(1) - expect(fills.filter((fill) => typeof fill.style !== 'string')).toHaveLength(1) - expect(strokes).toContainEqual({ - style: expected.rim, - lineWidth: expected.width, - arc: { radius: expected.blackRadius - expected.width / 2 }, - }) - } + it('uses at most one status ring and one selection ring', () => { + expect(outerRings(paint(IN_PROGRESS).paints)).toHaveLength(1) + expect(outerRings(paint(READY_CHILD).paints)).toHaveLength(1) + expect(outerRings(paint(BLOCKED).paints)).toHaveLength(0) + expect(outerRings(paint(RESOLVED).paints)).toHaveLength(0) + expect(outerRings(paint({ ...BLOCKED, parentIssue: 99 }).paints)).toHaveLength(0) + expect(outerRings(paint(READY_CHILD, null, READY_CHILD.num).paints)).toHaveLength(2) + expect(outerRings(paint(BLOCKED, null, BLOCKED.num).paints)).toHaveLength(1) }) - it('keeps both CURRENT rings around an incomplete issue after its hollow core', () => { - const { fills, strokes } = paint(BLOCKED, BLOCKED.num) + it('keeps CURRENT as label semantics without drawing CURRENT rings', () => { + const { paints, texts } = paint(BLOCKED, BLOCKED.num) - expect(fills).toHaveLength(2) - expect(fills[1]).toEqual({ style: '#000', arc: { radius: 5.625 } }) - expect(strokes.map((stroke) => ({ style: stroke.style, lineWidth: stroke.lineWidth, radius: stroke.arc?.radius }))).toEqual([ - { style: 'rgba(226,195,195,0.95)', lineWidth: 2.2, radius: 4.525 }, - { style: 'rgba(255,255,255,0.95)', lineWidth: 2, radius: 13.625 }, - { style: 'rgba(255,255,255,0.55)', lineWidth: 1, radius: 18.625 }, - ]) + expect(outerRings(paints)).toHaveLength(0) + expect(texts.some((text) => text.startsWith('CURRENT'))).toBe(true) }) - it('adds one relationship rim outside an incomplete child core and none to a resolved child', () => { - const incomplete = paint(INCOMPLETE_CHILD) - const relationshipRims = incomplete.strokes.filter((stroke) => stroke.style === 'rgba(170,145,255,0.82)') - const blackCore = incomplete.fills.find((fill) => fill.style === '#000')! + it('does not turn a selected state-change flare into another ring', () => { + const recording = recordingContext() + getContext.mockReturnValue(recording.ctx as never) + const host = document.createElement('div') + Object.defineProperty(host, 'clientWidth', { value: 1000 }) + Object.defineProperty(host, 'clientHeight', { value: 700 }) + document.body.appendChild(host) + const map = new StarMap() + map.mount(host) + map.setModel([BLOCKED]) + map.setModel([{ ...BLOCKED, workPriority: 'in_progress' }]) + map.select(BLOCKED.num) + now.mockReturnValue(16) + const frame = frames.pop() + frames = [] + frame?.(0) - expect(relationshipRims).toHaveLength(1) - expect(relationshipRims[0].arc!.radius).toBeGreaterThan(blackCore.arc!.radius) - expect(incomplete.strokes).toContainEqual({ - style: 'rgba(226,195,195,0.95)', - lineWidth: 2.2, - arc: { radius: 4.525 }, - }) - expect(paint(RESOLVED_CHILD).strokes.some((stroke) => stroke.style === 'rgba(170,145,255,0.82)')).toBe(false) - expect(paint(OUT_OF_SCOPE_CHILD).strokes.some((stroke) => stroke.style === 'rgba(170,145,255,0.82)')).toBe(false) - }) + const rings = outerRings(recording.paints) + const bodyRadius = bodyFill(recording.paints).arc!.radius + map.destroy() - it('keeps READY and CURRENT emphasis strokes dominant over the relationship rim', () => { - const ready = paint(READY_CHILD) - const readyStyles = ready.strokes.map((stroke) => stroke.style) - const readyRelationshipIndex = readyStyles.indexOf('rgba(170,145,255,0.82)') - const readyRelationshipRadius = ready.strokes[readyRelationshipIndex].arc!.radius - const readyEmphasisIndexes = ready.strokes - .map((stroke, index) => - stroke.style === 'rgba(138,216,255,0.95)' && - (stroke.arc?.radius ?? 0) > readyRelationshipRadius - ? index - : -1, - ) - .filter((index) => index >= 0) - expect(readyRelationshipIndex).toBeGreaterThanOrEqual(0) - expect(readyEmphasisIndexes).toHaveLength(1) - expect(readyEmphasisIndexes[0]).toBeGreaterThan(readyRelationshipIndex) + expect(rings).toHaveLength(2) + expect(Math.min(...rings.map((ring) => ring.arc!.radius))).toBeGreaterThanOrEqual(bodyRadius + 7) + }) - const current = paint(INCOMPLETE_CHILD, INCOMPLETE_CHILD.num) - const currentStyles = current.strokes.map((stroke) => stroke.style) - const relationshipIndex = currentStyles.indexOf('rgba(170,145,255,0.82)') - const whiteRingIndexes = currentStyles - .map((style, index) => typeof style === 'string' && style.startsWith('rgba(255,255,255,') ? index : -1) - .filter((index) => index >= 0) - expect(whiteRingIndexes).toHaveLength(2) - expect(whiteRingIndexes.every((index) => index > relationshipIndex)).toBe(true) - const relationshipRadius = current.strokes[relationshipIndex].arc!.radius - for (const index of whiteRingIndexes) { - expect(current.strokes[index].arc!.radius).toBeGreaterThan(relationshipRadius) + it('holds animated glow geometry and alpha still under reduced motion', () => { + const recording = recordingContext() + getContext.mockReturnValue(recording.ctx as never) + const host = document.createElement('div') + Object.defineProperty(host, 'clientWidth', { value: 1000 }) + Object.defineProperty(host, 'clientHeight', { value: 700 }) + document.body.appendChild(host) + const map = new StarMap() + map.setReducedMotion(true) + map.mount(host) + map.setModel([BLOCKED]) + map.setModel([{ ...BLOCKED, workPriority: 'in_progress' }]) + + const takeGlow = () => { + recording.paints.length = 0 + const frame = frames.shift() + frame?.(0) + return structuredClone(recording.paints[0]) } + + const first = takeGlow() + const second = takeGlow() + map.destroy() + expect(second).toEqual(first) }) }) diff --git a/web/src/lib/starmap/edge-visual.test.ts b/web/src/lib/starmap/edge-visual.test.ts index 1133270..29b0bc1 100644 --- a/web/src/lib/starmap/edge-visual.test.ts +++ b/web/src/lib/starmap/edge-visual.test.ts @@ -219,10 +219,7 @@ describe('dependency-edge visual treatment', () => { expect(unresolvedArrow.alpha).toBe(0.45) for (const arrow of [resolvedArrow, unresolvedArrow]) { expect(arrow.points).toHaveLength(3) - const [tip, baseA, baseB] = arrow.points - const base = { x: (baseA.x + baseB.x) / 2, y: (baseA.y + baseB.y) / 2 } - expect(Math.hypot(tip.x - base.x, tip.y - base.y)).toBeCloseTo(12) - expect(Math.hypot(baseA.x - base.x, baseA.y - base.y)).toBeCloseTo(6.5) + expectArrowDimensions(arrow, 8, 4) } expectParticleMotion(focused, 1) @@ -267,11 +264,17 @@ describe('dependency-edge visual treatment', () => { const resolvedArrow = selected.fills.find((fill) => fill.color === '#d9f3df')! const unresolvedArrows = selected.fills.filter((fill) => fill.color === '#c8d5e8') expect(resolvedArrow.alpha).toBe(1) - expectArrowDimensions(resolvedArrow, 15, 8.125) + expectArrowDimensions(resolvedArrow, 8, 4) const selectedUnresolvedArrow = unresolvedArrows.find((fill) => fill.alpha === 1)! const contextUnresolvedArrow = unresolvedArrows.find((fill) => fill.alpha === 0.45)! - expectArrowDimensions(selectedUnresolvedArrow, 15, 8.125) - expectArrowDimensions(contextUnresolvedArrow, 12, 6.5) + expectArrowDimensions(selectedUnresolvedArrow, 8, 4) + expectArrowDimensions(contextUnresolvedArrow, 8, 4) + expect(arrowDimensions(selectedUnresolvedArrow).length).toBeCloseTo( + arrowDimensions(contextUnresolvedArrow).length, + ) + expect(arrowDimensions(selectedUnresolvedArrow).halfWidth).toBeCloseTo( + arrowDimensions(contextUnresolvedArrow).halfWidth, + ) expectParticleMotion(selected, 1) const deselected = edgeStrokes(deselectedRender) @@ -364,6 +367,7 @@ describe('dependency-edge visual treatment', () => { expect(firstCurve.control.y + secondCurve.control.y).toBeCloseTo(sharedMidpoint.y * 2) for (let index = 0; index < violetArrows.length; index++) { + expectArrowDimensions(violetArrows[index], 8, 4) const [tip, baseA, baseB] = violetArrows[index].points const base = { x: (baseA.x + baseB.x) / 2, y: (baseA.y + baseB.y) / 2 } const stroke = violetStrokes[index] @@ -410,7 +414,7 @@ describe('dependency-edge visual treatment', () => { const selectedMiniArrows = selectedChild.fills.filter((fill) => fill.color === '#c7b8ff') expect(selectedMiniArrows).toHaveLength(2) for (const arrow of selectedMiniArrows) { - expectArrowDimensions(arrow, 15, 8.125) + expectArrowDimensions(arrow, 8, 4) } const completed = paintChild('resolved') @@ -418,6 +422,7 @@ describe('dependency-edge visual treatment', () => { const mintArrows = completed.fills.filter((fill) => fill.color === '#d9f3df') expect(mintStrokes).toHaveLength(2) expect(mintArrows).toHaveLength(2) + for (const arrow of mintArrows) expectArrowDimensions(arrow, 8, 4) for (const stroke of mintStrokes) { expect(stroke).toMatchObject({ width: 3, dash: [], cap: 'round', alpha: 1 }) expect(stroke.curves).toHaveLength(1) diff --git a/web/src/lib/starmap/model.ts b/web/src/lib/starmap/model.ts index dcbd1b1..def7cd5 100644 --- a/web/src/lib/starmap/model.ts +++ b/web/src/lib/starmap/model.ts @@ -1,5 +1,7 @@ // Derived from chartr (https://github.com/rengwu/chartr), MIT, Copyright (c) 2026 John Goh. +import type { WorkPriority } from './work-priority' + export type TicketStatus = | 'open' | 'blocked' @@ -18,6 +20,7 @@ export interface Ticket { parentIssue: number | null frontier: boolean readyForAgent?: boolean + workPriority?: WorkPriority } export interface Map { diff --git a/web/src/lib/starmap/starmap.test.ts b/web/src/lib/starmap/starmap.test.ts index 06ba59d..d0f9ee1 100644 --- a/web/src/lib/starmap/starmap.test.ts +++ b/web/src/lib/starmap/starmap.test.ts @@ -593,7 +593,7 @@ describe('label placement', () => { // Like stubContext, but recording where each string landed and how it was // aligned, so the drawn boxes can be reconstructed. function recordingContext() { - const drawn: { text: string; x: number; y: number; align: string; font: string }[] = [] + const drawn: { text: string; x: number; y: number; align: string; font: string; fill: string }[] = [] const ctx: Record = { textAlign: 'center', font: '', @@ -606,6 +606,7 @@ describe('label placement', () => { y, align: this.textAlign as string, font: this.font as string, + fill: this.fillStyle as string, }) }, } @@ -623,7 +624,12 @@ describe('label placement', () => { globalThis.requestAnimationFrame = realRaf }) - function place(tickets: Ticket[], sessions: Record = {}) { + function place( + tickets: Ticket[], + sessions: Record = {}, + currentIssue: number | null = null, + selectedIssue: number | null = null, + ) { const { ctx, drawn } = recordingContext() let frames: FrameRequestCallback[] = [] HTMLCanvasElement.prototype.getContext = (() => ctx) as never @@ -634,7 +640,8 @@ describe('label placement', () => { globalThis.cancelAnimationFrame = (() => {}) as never const { sm } = mounted() - sm.setModel(tickets, sessions) + sm.setModel(tickets, sessions, currentIssue) + if (selectedIssue !== null) sm.select(selectedIssue) const cb = frames.pop() frames = [] cb?.(0) @@ -656,6 +663,24 @@ describe('label placement', () => { return { sm, labels, boxes } } + it('gives label slots and colours to selected, current, then workflow priority', () => { + const tickets: Ticket[] = [ + { num: 5, slug: '5', title: 'Selected blocked', type: 'task', status: 'open', blockedBy: [], parentIssue: null, frontier: false, workPriority: 'blocked' }, + { num: 6, slug: '6', title: 'Current blocked', type: 'task', status: 'open', blockedBy: [], parentIssue: null, frontier: false, workPriority: 'blocked' }, + { num: 40, slug: '40', title: 'In progress', type: 'task', status: 'open', blockedBy: [], parentIssue: null, frontier: false, workPriority: 'in_progress' }, + { num: 30, slug: '30', title: 'Ready', type: 'task', status: 'open', blockedBy: [], parentIssue: null, frontier: true, readyForAgent: true, workPriority: 'ready' }, + { num: 20, slug: '20', title: 'Frontier', type: 'task', status: 'open', blockedBy: [], parentIssue: null, frontier: true, workPriority: 'frontier' }, + { num: 10, slug: '10', title: 'Blocked', type: 'task', status: 'open', blockedBy: [], parentIssue: null, frontier: false, workPriority: 'blocked' }, + ] + + const { labels } = place(tickets, {}, 6, 5) + const issueNumber = (text: string) => Number(text.match(/\b(\d+)\b/)?.[1]) + + expect(labels.map((label) => issueNumber(label.text))).toEqual([5, 6, 40, 30, 20, 10]) + expect(labels.find((label) => issueNumber(label.text) === 40)?.fill).toBe('#ffe6a0') + expect(labels.find((label) => issueNumber(label.text) === 30)?.fill).toBe('#b3e5ff') + }) + const overlaps = (a: { x0: number; y0: number; x1: number; y1: number }, b: { x0: number; y0: number; x1: number; y1: number }) => a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1 diff --git a/web/src/lib/starmap/starmap.ts b/web/src/lib/starmap/starmap.ts index 3874247..3260274 100644 --- a/web/src/lib/starmap/starmap.ts +++ b/web/src/lib/starmap/starmap.ts @@ -18,7 +18,14 @@ // Derived from chartr (https://github.com/rengwu/chartr), MIT, Copyright (c) 2026 John Goh. import { computeLayout, structureSignature, TAU } from './layout' -import { STAR, LABEL, SESSION_HUE, visualState, hexA, type VisualState } from './theme' +import { + SESSION_HUE, + visualState, + priorityLabelColor, + priorityStarStyle, + hexA, + type VisualState, +} from './theme' import { GRAMMAR, type SessionState } from './session' import { analyzeFocus, type Focus } from './focus' import { edgeKey, isMiniWorkflowEdge, workflowEdges, type WorkflowEdge } from './workflow' @@ -29,6 +36,7 @@ import { type WorkflowVisualState, } from './workflow-visual' import type { Ticket } from './model' +import { ticketWorkPriority, type WorkPriority } from './work-priority' export type SelectHandler = (num: number | null) => void @@ -41,8 +49,6 @@ const ISSUE_RADIUS_SCALE = 1.25 const CONTEXT_ALPHA = 0.3 const CONTEXT_EDGE_ALPHA = 0.45 const SELECTED_EDGE_WIDTH_SCALE = 1.7 -const SELECTED_EDGE_ARROW_SCALE = 1.25 -const SUBISSUE_RIM = 'rgba(170,145,255,0.82)' interface RenderEdge extends WorkflowEdge { state: WorkflowVisualState @@ -56,6 +62,7 @@ interface Node { type: string parentIssue: number | null vstate: VisualState + priority: WorkPriority // The session overlay riding this star, or null when no session speaks for it // (ticket 13). Strictly additive: it never touches layout or the base star. sstate: SessionState | null @@ -89,17 +96,9 @@ function hits(a: Box, b: Box): boolean { } // Who gets first pick of the good slots. A contested slot should go to the star -// the operator is most likely to be reading — the selected one, then the bright -// actionable states — rather than to whichever ticket happens to be numbered -// lowest, which is what array order gave us. -const LABEL_PRIORITY: Record = { - frontier: 0, - claimed: 1, - resolved: 2, - blocked: 3, - out_of_scope: 4, -} - +// the operator is most likely to be reading — selected, distinct CURRENT, then +// the approved workflow priority — rather than to whichever ticket happens to +// be numbered lowest, which is what array order gave us. // A star just off-screen can still own a label that reaches back on-screen, so // the viewport cull keeps a generous skirt around the canvas. const CULL_MARGIN = 140 @@ -294,6 +293,7 @@ export class StarMap { // operator last saw it rather than defaulting it below. #labelSide = new Map() #bg = DEFAULT_BG + #reducedMotion = false #onSelect: SelectHandler = () => {} #ro: ResizeObserver | null = null #detach: (() => void)[] = [] @@ -355,12 +355,14 @@ export class StarMap { n.title = t.title n.type = t.type const vstate = visualState(t) + const priority = ticketWorkPriority(t) const sstate = sessions[t.num] ?? null - if (vstate !== n.vstate || sstate !== n.sstate) { + if (vstate !== n.vstate || priority !== n.priority || sstate !== n.sstate) { n.flare = 1 changed.push(`#${t.num < 10 ? '0' : ''}${t.num} → ${sstate ?? vstate.replace('_', ' ')}`) } n.vstate = vstate + n.priority = priority n.sstate = sstate } if (changed.length) this.#tick(changed.join(' · ')) @@ -388,6 +390,7 @@ export class StarMap { type: t.type, parentIssue: t.parentIssue, vstate: visualState(t), + priority: ticketWorkPriority(t), sstate: sessions[t.num] ?? null, x: p.x, y: p.y, @@ -419,6 +422,10 @@ export class StarMap { this.#onSelect = cb } + setReducedMotion(reduced: boolean): void { + this.#reducedMotion = reduced + } + // Programmatic selection (a deep-link naming a star, or ticket 07's pane): the // camera eases the star into the space the pane leaves free. select(num: number | null): void { @@ -548,7 +555,7 @@ export class StarMap { } #radius(n: Node): number { - return STAR[n.vstate].r * ISSUE_RADIUS_SCALE + return priorityStarStyle(n.vstate, n.priority).r * ISSUE_RADIUS_SCALE } // Hit-test a screen point and, if it lands on a star, select and emit it — the @@ -1021,9 +1028,8 @@ export class StarMap { al = Math.hypot(tangentX, tangentY) || 1, ux = tangentX / al, uy = tangentY / al - const arrowScale = selected ? SELECTED_EDGE_ARROW_SCALE : 1 - const ah = 12 * arrowScale, - aw = 6.5 * arrowScale, + const ah = 8, + aw = 4, px = -uy, py = ux, tipx = midx + ux * ah * 0.5, @@ -1038,13 +1044,12 @@ export class StarMap { } #drawStar(g: CanvasRenderingContext2D, n: Node, t: number): void { - const c = STAR[n.vstate] + const c = priorityStarStyle(n.vstate, n.priority) const x = n._x, y = n._y, - fl = n.flare || 0 - const isF = n.vstate === 'frontier', - isC = n.vstate === 'claimed' - const beat = 0.5 + 0.5 * Math.sin(t * 2.8) + fl = this.#reducedMotion ? (n.flare > 0 ? 1 : 0) : n.flare || 0 + const isF = n.priority === 'ready' || n.priority === 'frontier' + const beat = this.#reducedMotion ? 0.5 : 0.5 + 0.5 * Math.sin(t * 2.8) const pulse = isF ? 0.8 + 0.2 * beat : 1 const gr = (isF ? c.gr * (0.92 + 0.16 * beat) : c.gr) * (1 + fl * 0.5) @@ -1058,85 +1063,79 @@ export class StarMap { g.fill() const cr = this.#radius(n) - const hasSubissueRim = n.parentIssue !== null && n.vstate !== 'resolved' && n.vstate !== 'out_of_scope' - if (hasSubissueRim) { - g.strokeStyle = SUBISSUE_RIM - g.lineWidth = 1.5 - g.beginPath() - g.arc(x, y, cr + 4, 0, TAU) - g.stroke() - } - if (n.vstate === 'resolved') { - const cg = g.createRadialGradient(x, y, 0, x, y, cr * 1.35) - cg.addColorStop(0, hexA(c.core, 1)) - cg.addColorStop(0.6, hexA(c.core, 0.92)) - cg.addColorStop(0.82, hexA(c.core, 0.45)) - cg.addColorStop(1, hexA(c.core, 0)) - g.fillStyle = cg - g.beginPath() - g.arc(x, y, cr * 1.35, 0, TAU) - g.fill() - } else { - g.fillStyle = '#000' - g.beginPath() - g.arc(x, y, cr, 0, TAU) - g.fill() - const lineWidth = Math.max(2.2, cr * 0.32) - g.strokeStyle = hexA(c.core, 0.95) - g.lineWidth = lineWidth - g.beginPath() - g.arc(x, y, cr - lineWidth / 2, 0, TAU) - g.stroke() - } + const shadowY = y + cr * 0.72 + const shadowRadius = cr * 1.08 + const shadow = g.createRadialGradient(x, shadowY, 0, x, shadowY, shadowRadius) + shadow.addColorStop(0, 'rgba(0,0,0,0.26)') + shadow.addColorStop(1, 'rgba(0,0,0,0)') + g.fillStyle = shadow + g.beginPath() + g.arc(x, shadowY, shadowRadius, 0, TAU) + g.fill() - if (hasSubissueRim && this.#focus.readySet.has(n.num) && this.#focus.current !== n.num) { - g.strokeStyle = hexA(c.core, 0.95) - g.lineWidth = 2 - g.beginPath() - g.arc(x, y, cr + 8, 0, TAU) - g.stroke() - } + const body = g.createRadialGradient( + x - cr * 0.32, + y - cr * 0.34, + 0, + x, + y, + cr * 1.12, + ) + body.addColorStop(0, hexA(c.core, 1)) + body.addColorStop(0.48, hexA(c.core, 0.98)) + body.addColorStop(0.82, hexA(c.glow, 0.92)) + body.addColorStop(1, hexA(c.glow, 0.62)) + g.fillStyle = body + g.beginPath() + g.arc(x, y, cr, 0, TAU) + g.fill() - if (fl > 0) { - g.strokeStyle = hexA(c.core, fl * 0.7) - g.lineWidth = 1.5 + 2 * fl - g.beginPath() - g.arc(x, y, cr + (1 - fl) * 40, 0, TAU) - g.stroke() - } - // A live claim breathes with two soft rings. A session overlay speaks for - // the claim when there is one, so the vanilla claimed rings stand down rather - // than competing with the moon's orbit. - if (isC && !n.sstate) { + const specularX = x - cr * 0.32 + const specularY = y - cr * 0.34 + const specularRadius = cr * 0.28 + const specular = g.createRadialGradient( + specularX, + specularY, + 0, + specularX, + specularY, + specularRadius, + ) + specular.addColorStop(0, 'rgba(255,255,255,0.46)') + specular.addColorStop(1, 'rgba(255,255,255,0)') + g.fillStyle = specular + g.beginPath() + g.arc(specularX, specularY, specularRadius, 0, TAU) + g.fill() + + const boundaryWidth = Math.max(1, cr * 0.14) + g.strokeStyle = hexA(c.core, 0.72) + g.lineWidth = boundaryWidth + g.beginPath() + g.arc(x, y, cr - boundaryWidth / 2, 0, TAU) + g.stroke() + + const hasStatusRing = n.priority === 'in_progress' || n.priority === 'ready' + if (n.priority === 'in_progress') { g.strokeStyle = hexA(c.core, 0.45 + 0.25 * beat) g.lineWidth = 1.5 g.beginPath() - g.arc(x, y, cr + 5 + 1.2 * beat, 0, TAU) + g.arc(x, y, cr + 7 + beat, 0, TAU) g.stroke() - g.strokeStyle = hexA(c.core, 0.18 + 0.14 * beat) - g.lineWidth = 1 + } else if (n.priority === 'ready') { + g.strokeStyle = hexA(c.core, 0.82) + g.lineWidth = 1.5 g.beginPath() - g.arc(x, y, cr + 11 + 1.8 * beat, 0, TAU) + g.arc(x, y, cr + 7, 0, TAU) g.stroke() } + if (n.sstate) this.#drawSession(g, n, x, y, cr, t) - if (this.#focus.current === n.num) { - g.strokeStyle = 'rgba(255,255,255,0.95)' - g.lineWidth = 2 - g.beginPath() - g.arc(x, y, cr + 8, 0, TAU) - g.stroke() - g.strokeStyle = 'rgba(255,255,255,0.55)' - g.lineWidth = 1 - g.beginPath() - g.arc(x, y, cr + 13, 0, TAU) - g.stroke() - } if (this.#selected === n.num) { g.strokeStyle = 'rgba(255,255,255,0.85)' g.lineWidth = 1.5 g.beginPath() - g.arc(x, y, cr + (this.#focus.current === n.num ? 18 : 13), 0, TAU) + g.arc(x, y, cr + (hasStatusRing ? 13 : 7), 0, TAU) g.stroke() } } @@ -1322,8 +1321,9 @@ export class StarMap { const core = this.#radius(n) let r = core + 2 if (n.sstate) r = core + 15 - if (this.#focus.current === n.num) r = Math.max(r, core + 14) - if (this.#selected === n.num) r = Math.max(r, core + 19) + const hasStatusRing = n.priority === 'in_progress' || n.priority === 'ready' + if (hasStatusRing) r = Math.max(r, core + 9) + if (this.#selected === n.num) r = Math.max(r, core + (hasStatusRing ? 15 : 9)) vis.push({ n, sx, sy, rad: r * s }) } @@ -1336,11 +1336,15 @@ export class StarMap { const order = [...vis].sort((a, b) => { const priority = (n: Node) => { - if (this.#focus.current === n.num) return 0 - if (this.#focus.readySet.has(n.num)) return 1 - if (this.#selected === n.num) return 2 - if (this.#focus.pathNodes.has(n.num)) return 3 - return 4 + LABEL_PRIORITY[n.vstate] + if (this.#selected === n.num) return 0 + if (this.#focus.current === n.num) return 1 + switch (n.priority) { + case 'in_progress': return 2 + case 'ready': return 3 + case 'frontier': return 4 + case 'blocked': return 5 + case 'terminal': return n.vstate === 'resolved' ? 6 : 7 + } } const pa = priority(a.n) const pb = priority(b.n) @@ -1406,7 +1410,7 @@ export class StarMap { text, x: v.sx, y: c.y, - fill: LABEL[v.n.vstate], + fill: priorityLabelColor(v.n.vstate, v.n.priority), alpha: this.#focus.emphasized.size === 0 || this.#focus.emphasized.has(v.n.num) ? 1 diff --git a/web/src/lib/starmap/theme.test.ts b/web/src/lib/starmap/theme.test.ts new file mode 100644 index 0000000..921185b --- /dev/null +++ b/web/src/lib/starmap/theme.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { priorityLabelColor, priorityStarStyle } from './theme' + +describe('priority visual theme', () => { + it('orders active work without changing its semantic palette', () => { + expect(priorityStarStyle('blocked', 'in_progress')).toMatchObject({ + core: '#ffd873', + r: 8.1, + gr: 42, + }) + expect(priorityStarStyle('frontier', 'ready')).toMatchObject({ + core: '#8ad8ff', + r: 7.2, + gr: 34, + }) + expect(priorityStarStyle('frontier', 'frontier')).toMatchObject({ + core: '#8ad8ff', + r: 6.2, + gr: 28, + }) + expect(priorityStarStyle('blocked', 'blocked')).toMatchObject({ + core: '#e2c3c3', + r: 4.5, + gr: 20, + }) + expect(priorityStarStyle('resolved', 'terminal')).toMatchObject({ + core: '#b9d6c4', + r: 5.4, + gr: 24, + }) + expect(priorityStarStyle('out_of_scope', 'terminal')).toMatchObject({ + core: '#948da4', + r: 4.5, + gr: 18, + }) + expect(priorityLabelColor('blocked', 'in_progress')).toBe('#ffe6a0') + expect(priorityLabelColor('frontier', 'ready')).toBe('#b3e5ff') + }) +}) diff --git a/web/src/lib/starmap/theme.ts b/web/src/lib/starmap/theme.ts index 900614d..bd0d2c5 100644 --- a/web/src/lib/starmap/theme.ts +++ b/web/src/lib/starmap/theme.ts @@ -14,6 +14,7 @@ // Derived from chartr (https://github.com/rengwu/chartr), MIT, Copyright (c) 2026 John Goh. import type { Ticket } from './model' +import type { WorkPriority } from './work-priority' export type VisualState = | 'resolved' @@ -54,6 +55,19 @@ export const LABEL: Record = { out_of_scope: '#a89fb2', } +export function priorityStarStyle(vstate: VisualState, priority: WorkPriority): StarStyle { + if (priority === 'in_progress') return { ...STAR.claimed, r: 8.1, gr: 42 } + if (priority === 'ready') return { ...STAR.frontier, r: 7.2, gr: 34 } + if (priority === 'frontier') return { ...STAR.frontier, r: 6.2, gr: 28 } + return STAR[vstate] +} + +export function priorityLabelColor(vstate: VisualState, priority: WorkPriority): string { + if (priority === 'in_progress') return LABEL.claimed + if (priority === 'ready' || priority === 'frontier') return LABEL.frontier + return LABEL[vstate] +} + // Derive the visual state of a ticket from its pushed status and frontier flag. // The frontier flag is what splits an open ticket into the bright, takeable // `frontier` star and the small, dim `blocked` one — the whole reason the map diff --git a/web/src/lib/starmap/work-priority.test.ts b/web/src/lib/starmap/work-priority.test.ts new file mode 100644 index 0000000..0d8c976 --- /dev/null +++ b/web/src/lib/starmap/work-priority.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { deriveWorkPriority } from './work-priority' + +describe('deriveWorkPriority', () => { + it.each([ + ['resolved overrides stale labels', 'resolved', ['in-progress'], ['ada'], 'terminal'], + ['out of scope overrides stale labels', 'out_of_scope', ['ready-for-agent'], [], 'terminal'], + ['literal in progress outranks a blocker', 'blocked', ['IN-PROGRESS'], [], 'in_progress'], + ['claimed is the assigned fallback', 'claimed', [], ['ada'], 'in_progress'], + ['an assignee is the assigned fallback', 'frontier', [], ['ada'], 'in_progress'], + ['ready requires an unblocked frontier', 'frontier', ['READY-FOR-AGENT'], [], 'ready'], + ['ready cannot override blocked', 'blocked', ['ready-for-agent'], [], 'blocked'], + ['unlabelled unblocked work stays frontier', 'frontier', [], [], 'frontier'], + ] as const)('%s', (_name, status, labels, assignees, expected) => { + expect(deriveWorkPriority({ status, labels, assignees })).toBe(expected) + }) +}) diff --git a/web/src/lib/starmap/work-priority.ts b/web/src/lib/starmap/work-priority.ts new file mode 100644 index 0000000..5df4bb1 --- /dev/null +++ b/web/src/lib/starmap/work-priority.ts @@ -0,0 +1,32 @@ +import type { Status } from '../model' +import type { Ticket } from './model' + +export type WorkPriority = 'in_progress' | 'ready' | 'frontier' | 'blocked' | 'terminal' + +export interface WorkPriorityInput { + status: Status + labels: readonly string[] + assignees: readonly string[] +} + +function hasLabel(labels: readonly string[], expected: string): boolean { + return labels.some((label) => label.toLowerCase() === expected) +} + +export function deriveWorkPriority(input: WorkPriorityInput): WorkPriority { + if (input.status === 'resolved' || input.status === 'out_of_scope') return 'terminal' + if (hasLabel(input.labels, 'in-progress')) return 'in_progress' + if (input.status === 'claimed' || input.assignees.length > 0) return 'in_progress' + if (input.status === 'frontier' && hasLabel(input.labels, 'ready-for-agent')) return 'ready' + return input.status === 'blocked' ? 'blocked' : 'frontier' +} + +export function ticketWorkPriority( + ticket: Pick, +): WorkPriority { + if (ticket.workPriority) return ticket.workPriority + if (ticket.status === 'resolved' || ticket.status === 'out_of_scope') return 'terminal' + if (ticket.status === 'claimed') return 'in_progress' + if (ticket.readyForAgent) return 'ready' + return ticket.frontier ? 'frontier' : 'blocked' +}