From 13749ab7e4e13ada4541768a1c6bcf1bd6573a0c Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:02:41 -0500 Subject: [PATCH 01/17] docs: design work-priority visual hierarchy --- ...8-work-priority-visual-hierarchy-design.md | 238 ++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-08-work-priority-visual-hierarchy-design.md diff --git a/docs/superpowers/specs/2026-08-08-work-priority-visual-hierarchy-design.md b/docs/superpowers/specs/2026-08-08-work-priority-visual-hierarchy-design.md new file mode 100644 index 0000000..c26bb76 --- /dev/null +++ b/docs/superpowers/specs/2026-08-08-work-priority-visual-hierarchy-design.md @@ -0,0 +1,238 @@ +# Work-Priority Visual Hierarchy Design + +## Goal + +Make Stellr's live issue graph answer four questions in order: + +1. What needs my attention now? +2. What am I doing now? +3. What should I do next or later? +4. What team and historical context should remain visible? + +The map must keep the complete issue graph visible without letting completed +paths or other people's assignments compete with the operator's active work. +Active nodes must retain a readable screen-space size when the camera is +zoomed out. + +## Status Model + +Keep `claimed` compatible with its current meaning: an open issue assigned to +one or more people. Do not redefine it as "assigned to me." + +Add orthogonal facts instead of expanding the mutually exclusive status enum: + +- `viewer_login`: the authenticated GitHub login, when known; +- `blocked`: the issue is open and at least one known blocker is open; +- `ready_for_agent`: the issue is open, is not blocked, and carries the + case-insensitive `ready-for-agent` label; +- `assignees`: the existing list of GitHub logins; +- session liveness: the existing local implementing, blocked, or dead overlay; +- closure reason: the existing resolved versus out-of-scope distinction. + +`assigned_to_viewer` is true when `viewer_login` case-insensitively matches an +assignee. An issue assigned to the viewer can therefore also be ready or +blocked. The existing `Status` enum remains available to current consumers and +keeps its current precedence and serialized values. + +## Viewer Identity and Offline Behavior + +Add `viewer { login }` to the existing GitHub GraphQL issue request. Return the +login with the fetched issues from the provider boundary. Pagination must +retain one stable login while accumulating issue pages. + +Store the optional viewer login in the per-repository cache snapshot and expose +it on `SpaceModel`. Older caches without the field remain readable through a +serde default. A failed live refresh uses both cached issues and cached viewer +identity. + +When native device authorization replaces the active credential, cached issues +may remain visible but cached viewer identity must not be applied until the new +credential completes one successful live fetch. This prevents an account +change from temporarily presenting the previous account's assignments as My +work. + +If no viewer identity is available, Stellr must not guess. Assigned issues are +rendered as team work and no issue is classified as assigned to the viewer. +Local session evidence can still identify Doing now or the attention override, +because it is authoritative evidence from this Stellr instance. The existing +launcher-supplied CURRENT issue is also authoritative Doing-now evidence; a +merely selected detail-pane issue is not. + +## Priority Derivation + +Derive one renderer priority for every issue in this order: + +| Priority | Name | Condition | +| --- | --- | --- | +| Override | Needs attention | A local session on the issue is blocked or dead | +| P1 | Doing now | The launcher-supplied CURRENT issue, or a local session on the issue is implementing | +| P2 | My next work | Open, assigned to the viewer, and ready for agent | +| P3 | My future work | Any other open issue assigned to the viewer | +| P4 | Available next | Open, unassigned, and ready for agent | +| P5 | Team work | Open and assigned to someone other than the viewer | +| P6 | Planning or waiting | Any remaining open issue | +| P7 | Closed context | Resolved or out of scope | + +P3 includes dependency-blocked work as its primary case, but also retains an +owned issue that is waiting for information, ready for a human, or otherwise +not agent-ready. Ownership must not be demoted below unassigned work merely +because an issue lacks an actionability label. + +The attention override uses P1's core size and ownership treatment while the +existing session overlay continues to distinguish a waiting session from a +dead one. Issue blocking and session blocking remain separate concepts. + +Ordinary click selection remains a navigation overlay and never promotes an +issue. CURRENT keeps its existing navigation marker while its +launcher-supplied issue identity also provides the P1 Doing-now input. + +## Node Treatment + +The renderer uses these exact dark-canvas core colors and shape rules: + +| Priority | Core | Shape | Motion | +| --- | --- | --- | --- | +| Needs attention | `#ffd873` | solid | existing blocked/dead session grammar | +| Doing now | `#ffd873` | solid | core pulse plus existing orbiting session moon | +| My next work | `#8ad8ff` | solid | none | +| My future work | `#8ad8ff` | hollow | none | +| Available next | `#8ed7ac` | hollow | none | +| Team work | `#b9a7ee` | solid | none | +| Planning or waiting | `#aaa0bd` | hollow | none | +| Closed completed | existing `#b9d6c4` | solid | none | +| Closed not planned | existing `#948da4` | hollow | none | + +Doing now pulses its core radius between `1.00` and `1.08` of the derived +radius using the renderer's existing beat. The existing session moon is the +approved orbiting proton; do not add a second orbiting body. Reduced-motion +rendering freezes the pulse and orbit while preserving their shapes. + +Use the following base world-space radii and minimum screen-space radii: + +| Priority | Base world radius | Minimum screen radius | +| --- | ---: | ---: | +| Needs attention / Doing now | 12 | 10 px | +| My next work | 11 | 8.5 px | +| My future work | 10 | 7 px | +| Available next | 10 | 7 px | +| Team work | 9 | 6 px | + +The rendered world radius is `max(base_world_radius, +minimum_screen_radius / camera_scale)`. Lower priorities retain their existing +state radii without a screen-space floor. Glow, relationship rings, session +orbits, selection rings, label obstacles, and pointer hit areas use the derived +core radius so the visible and interactive geometry stays aligned. Layout +positions and camera fit do not change. + +## Traversed Connections + +An edge is traversed when its source issue is resolved and its directed +destination is still open. Resolved/completed workflow edges that do not meet +that condition remain historical edges, but they use the same quiet static +treatment. + +Traversed and other resolved/completed connections are contextual history by +default: + +- use a `1.6` world-pixel stroke in `rgba(150,178,160,0.36)` and an arrowhead + in `rgba(190,218,198,0.52)`, making them thinner and lower contrast than + active incomplete paths; +- paint no particles by default; +- preserve direction, arrowheads, curve geometry, and dependency versus + parent/subissue semantics; +- never let a selected edge regain motion that its workflow state does not + permit. + +Animate particles only when a traversed edge's directed `to` endpoint is +currently Doing now, My next work, or Available next. The test is direct and +directional: an incident edge pointing away from an active node remains static, +as does every transitive edge beyond the active node. + +An eligible edge paints exactly two `1.8` world-pixel particles with no halo. +Particle alpha follows `0.35 + 0.40 * sin(pi * u)`, retaining direction without +turning the historical line into a luminous path. Particle speed remains the +existing `0.1` curve-lengths per renderer second. + +The selected-node contract remains direct-only. Selected incoming and outgoing +edges still paint last with their approved width and arrowhead multipliers, but +motion eligibility is calculated independently from selection. + +## Renderer Boundaries + +Introduce a small pure priority-derivation module between adapted tickets and +canvas paint. It owns priority precedence and exposes the visual priority to the +renderer. Keep palette and geometry constants in the star-map visual layer. + +The adapter copies the new orthogonal fields and derives +`assignedToViewer` from `SpaceModel.viewer_login`. The pure priority seam also +receives CURRENT and session state. The canvas renderer consumes those derived +facts; it does not inspect GitHub labels, compare logins, or reconstruct blocker +state. + +Do not change graph topology, deterministic layout positions, pan/zoom +behavior, route state, detail-pane content, or GitHub write behavior. + +## Error Handling and Compatibility + +- Older cache snapshots load with `viewer_login = None`. +- Older serialized models load new booleans as false. +- Credential replacement suppresses cached viewer identity until a successful + fetch confirms the new account. +- Missing or malformed viewer data is a typed provider parse failure on a live + response; stale cached data remains available through the existing fallback. +- Unknown viewer identity produces Team work, never a false My-work result. +- A session whose GitHub assignment has not caught up still renders as Doing + now, because local live evidence has higher precedence. +- Existing showcase and historical status consumers keep the legacy `Status` + contract. + +## Verification + +Use test-driven development across the affected seams: + +1. Rust core tests prove `blocked` and `ready_for_agent` coexist with assignment + while legacy `claimed` remains unchanged. +2. GitHub provider tests prove viewer parsing on every pagination shape and + typed failure behavior. +3. Cache tests prove viewer identity round-trips and old snapshots default + safely. +4. Server/API tests prove live and stale `SpaceModel` snapshots expose the + correct viewer identity and orthogonal facts. +5. Adapter and pure priority tests cover every priority, unknown viewer, + case-insensitive login matching, CURRENT, ordinary selection, and session + overrides. +6. Canvas recording tests prove exact colors, solid/hollow shapes, pulse, + screen-size floors, session overlay alignment, and reduced-motion behavior. +7. Edge tests prove subtle static traversed treatment, direct directional + motion into only P1, P2, and P4, and selection-independent motion gating. +8. Run the complete native Windows frontend test, check, build, Rust format, + Clippy, and locked workspace test gates. +9. Perform a headed visual check at normal and zoomed-out camera scales. + +## Build and Running-App Handoff + +Implementation and validation occur in an isolated worktree under `D:\tmp` +using native Windows tools. Build the web bundle before the Rust application so +the embedded assets are current. + +Do not overwrite or uninstall `D:\Apps\Stellr`. Keep the currently installed +binary as the rollback target. After every automated gate passes: + +1. capture the exact running `stellr-desktop.exe` PID and executable path; +2. build a release `stellr-desktop.exe` from the implementation worktree; +3. stop only the captured Stellr desktop process; +4. launch the freshly built executable without arguments so it restores the + existing spaces and route from the shared Stellr app data; +5. verify a top-level Stellr window, WebView2 child, and the new graph visuals; +6. if the new process fails to establish a working window, stop it and relaunch + `D:\Apps\Stellr\stellr-desktop.exe`. + +The successful handoff leaves the development build running for user review. +It does not replace the installed application or produce a release installer. + +## Scope + +This slice changes live issue synchronization metadata and star-map visual +priority. It does not add GitHub writes, change assignment semantics, redesign +the detail pane, alter graph layout, add team-presence synchronization, modify +the installed application, or redesign historical showcase playback. From 1e2349a7258036f005ca38ea14d4d6d247b02e94 Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:16:02 -0500 Subject: [PATCH 02/17] feat: show viewer-owned next work (#91) --- crates/app/src/acceptance.rs | 4 +- crates/app/src/desktop.rs | 4 +- crates/app/src/runtime.rs | 4 +- crates/app/tests/auth_activation_test.rs | 10 +-- crates/app/tests/desktop_runtime_test.rs | 6 +- crates/app/tests/provider_activation_test.rs | 10 +-- crates/app/tests/runtime_test.rs | 4 +- crates/core/src/derive.rs | 35 +++++++++ crates/core/src/lib.rs | 2 +- crates/core/src/model.rs | 11 +++ crates/core/src/provider.rs | 17 ++++- crates/github/examples/fetch.rs | 6 +- crates/github/src/sync.rs | 33 +++++++-- crates/github/tests/sync_test.rs | 75 ++++++++++++++++++-- crates/server/src/poll.rs | 24 +++++-- crates/server/tests/api_test.rs | 50 +++++++------ crates/server/tests/focus_polling_test.rs | 6 +- web/src/lib/model.ts | 2 + web/src/lib/starmap/adapt.test.ts | 18 +++++ web/src/lib/starmap/adapt.ts | 31 ++++---- web/src/lib/starmap/core-visual.test.ts | 16 +++++ web/src/lib/starmap/model.ts | 1 + web/src/lib/starmap/starmap.ts | 16 +++-- web/src/lib/starmap/theme.ts | 8 ++- 24 files changed, 314 insertions(+), 79 deletions(-) diff --git a/crates/app/src/acceptance.rs b/crates/app/src/acceptance.rs index e6aefdd..5113b1f 100644 --- a/crates/app/src/acceptance.rs +++ b/crates/app/src/acceptance.rs @@ -2,7 +2,7 @@ use std::{path::PathBuf, sync::Arc}; -use stellr_core::{Provider, ProviderError, RawIssue, RepoRef}; +use stellr_core::{Provider, ProviderError, ProviderSnapshot, RepoRef}; use stellr_github::{ credentials::{CredentialStore, CredentialStoreError}, device_flow::{AccessToken, DeviceFlowClient, DeviceFlowController, DeviceFlowStatus}, @@ -26,7 +26,7 @@ struct SignedOut; #[async_trait::async_trait] impl Provider for SignedOut { - async fn fetch(&self, _repo: &RepoRef) -> Result, ProviderError> { + async fn fetch(&self, _repo: &RepoRef) -> Result { Err(ProviderError::Auth("GitHub sign-in required".into())) } } diff --git a/crates/app/src/desktop.rs b/crates/app/src/desktop.rs index e42fee7..70e220f 100644 --- a/crates/app/src/desktop.rs +++ b/crates/app/src/desktop.rs @@ -9,7 +9,7 @@ use std::{ }; use serde::Serialize; -use stellr_core::{Provider, ProviderError, RawIssue, RepoRef}; +use stellr_core::{Provider, ProviderError, ProviderSnapshot, RepoRef}; use stellr_github::{ auth::resolve_token, cache::Cache, @@ -231,7 +231,7 @@ struct SignedOutProvider; #[async_trait::async_trait] impl Provider for SignedOutProvider { - async fn fetch(&self, _repo: &RepoRef) -> Result, ProviderError> { + async fn fetch(&self, _repo: &RepoRef) -> Result { Err(ProviderError::Auth("GitHub sign-in required".to_owned())) } } diff --git a/crates/app/src/runtime.rs b/crates/app/src/runtime.rs index baeb176..ac92345 100644 --- a/crates/app/src/runtime.rs +++ b/crates/app/src/runtime.rs @@ -1,6 +1,6 @@ use std::{io, net::SocketAddr, num::NonZeroU64, path::PathBuf, sync::Arc, time::Duration}; -use stellr_core::{Model, Provider, ProviderError, RawIssue, RepoRef}; +use stellr_core::{Model, Provider, ProviderError, ProviderSnapshot, RepoRef}; use stellr_github::cache::Cache; use stellr_server::{ poll::{PollingControl, spawn_controlled_poller}, @@ -33,7 +33,7 @@ impl ProviderSlot { #[async_trait::async_trait] impl Provider for ProviderSlot { - async fn fetch(&self, repo: &RepoRef) -> Result, ProviderError> { + async fn fetch(&self, repo: &RepoRef) -> Result { let provider = self.current.read().await.clone(); provider.fetch(repo).await } diff --git a/crates/app/tests/auth_activation_test.rs b/crates/app/tests/auth_activation_test.rs index 8d40674..81de4ef 100644 --- a/crates/app/tests/auth_activation_test.rs +++ b/crates/app/tests/auth_activation_test.rs @@ -4,7 +4,7 @@ use std::sync::{ }; use stellr_app::{auth_activation::activate_provider_and_store, runtime::ProviderSlot}; -use stellr_core::{Provider, ProviderError, RawIssue, RepoRef}; +use stellr_core::{Provider, ProviderError, ProviderSnapshot, RepoRef}; use stellr_github::{ credentials::{CredentialStore, CredentialStoreError}, device_flow::AccessToken, @@ -15,7 +15,7 @@ struct SignedOut; #[async_trait::async_trait] impl Provider for SignedOut { - async fn fetch(&self, _repo: &RepoRef) -> Result, ProviderError> { + async fn fetch(&self, _repo: &RepoRef) -> Result { Err(ProviderError::Auth("sign-in required".into())) } } @@ -24,8 +24,8 @@ struct Active; #[async_trait::async_trait] impl Provider for Active { - async fn fetch(&self, _repo: &RepoRef) -> Result, ProviderError> { - Ok(vec![]) + async fn fetch(&self, _repo: &RepoRef) -> Result { + Ok(ProviderSnapshot::without_viewer(vec![])) } } @@ -69,5 +69,5 @@ async fn storage_failure_warns_after_activating_the_provider_and_refreshing() { owner: "teloverge".into(), name: "stellr".into(), }; - assert_eq!(slot.fetch(&repo).await.unwrap(), vec![]); + assert_eq!(slot.fetch(&repo).await.unwrap().issues, vec![]); } diff --git a/crates/app/tests/desktop_runtime_test.rs b/crates/app/tests/desktop_runtime_test.rs index 1de5055..97e39c6 100644 --- a/crates/app/tests/desktop_runtime_test.rs +++ b/crates/app/tests/desktop_runtime_test.rs @@ -1,15 +1,15 @@ use std::{path::Path, process::Command, sync::Arc}; use stellr_app::desktop::{DesktopRuntimeOptions, start_runtime, start_runtime_with_entry}; -use stellr_core::{Provider, ProviderError, RawIssue, RepoRef}; +use stellr_core::{Provider, ProviderError, ProviderSnapshot, RepoRef}; use stellr_server::spaces::{SpaceEntry, detect_repo}; struct EmptyProvider; #[async_trait::async_trait] impl Provider for EmptyProvider { - async fn fetch(&self, _repo: &RepoRef) -> Result, ProviderError> { - Ok(vec![]) + async fn fetch(&self, _repo: &RepoRef) -> Result { + Ok(ProviderSnapshot::without_viewer(vec![])) } } diff --git a/crates/app/tests/provider_activation_test.rs b/crates/app/tests/provider_activation_test.rs index b1eb30b..53a89b8 100644 --- a/crates/app/tests/provider_activation_test.rs +++ b/crates/app/tests/provider_activation_test.rs @@ -1,13 +1,13 @@ use std::sync::Arc; use stellr_app::runtime::ProviderSlot; -use stellr_core::{Provider, ProviderError, RawIssue, RepoRef}; +use stellr_core::{Provider, ProviderError, ProviderSnapshot, RepoRef}; struct SignedOut; #[async_trait::async_trait] impl Provider for SignedOut { - async fn fetch(&self, _repo: &RepoRef) -> Result, ProviderError> { + async fn fetch(&self, _repo: &RepoRef) -> Result { Err(ProviderError::Auth("sign-in required".into())) } } @@ -16,8 +16,8 @@ struct Active; #[async_trait::async_trait] impl Provider for Active { - async fn fetch(&self, _repo: &RepoRef) -> Result, ProviderError> { - Ok(vec![]) + async fn fetch(&self, _repo: &RepoRef) -> Result { + Ok(ProviderSnapshot::without_viewer(vec![])) } } @@ -36,5 +36,5 @@ async fn replacing_the_provider_activates_it_in_the_current_process() { slot.replace(Arc::new(Active)).await; - assert_eq!(slot.fetch(&repo).await.unwrap(), vec![]); + assert_eq!(slot.fetch(&repo).await.unwrap().issues, vec![]); } diff --git a/crates/app/tests/runtime_test.rs b/crates/app/tests/runtime_test.rs index 7751289..5262496 100644 --- a/crates/app/tests/runtime_test.rs +++ b/crates/app/tests/runtime_test.rs @@ -9,7 +9,7 @@ use std::{ }; use stellr_app::runtime::{RuntimeOptions, SessionAuth, start}; -use stellr_core::{Provider, ProviderError, RawIssue, RepoRef}; +use stellr_core::{Provider, ProviderError, ProviderSnapshot, RepoRef}; use stellr_server::spaces::{SpaceEntry, SpaceStore}; use tempfile::TempDir; use tokio::{sync::Notify, time::timeout}; @@ -29,7 +29,7 @@ impl Drop for FetchDropSignal { #[async_trait::async_trait] impl Provider for PendingProvider { - async fn fetch(&self, _repo: &RepoRef) -> Result, ProviderError> { + async fn fetch(&self, _repo: &RepoRef) -> Result { let _drop_signal = FetchDropSignal(self.fetch_dropped.clone()); self.fetch_started.notify_one(); pending().await diff --git a/crates/core/src/derive.rs b/crates/core/src/derive.rs index 9aac15e..4c11668 100644 --- a/crates/core/src/derive.rs +++ b/crates/core/src/derive.rs @@ -28,12 +28,19 @@ pub fn derive(issues: &[RawIssue]) -> Vec { IssueState::Open if has_open_blocker => Status::Blocked, IssueState::Open => Status::Frontier, }; + let ready_for_agent = issue.state == IssueState::Open + && !has_open_blocker + && issue + .labels + .iter() + .any(|label| label.eq_ignore_ascii_case("ready-for-agent")); Star { number: issue.number, parent_issue: issue.parent_issue.filter(|parent| *parent != issue.number), title: issue.title.clone(), status, + ready_for_agent, blocked_by, milestone: issue.milestone.clone(), labels: issue.labels.clone(), @@ -101,6 +108,34 @@ mod tests { assert_eq!(status_of(&stars, 5), Status::Frontier); } + #[test] + fn keeps_claimed_compatibility_while_exposing_agent_readiness() { + let mut ready_claimed = issue(1, IssueState::Open, &["octocat"], &[], None); + ready_claimed.labels = vec!["READY-FOR-AGENT".into()]; + let mut blocked_claimed = issue(2, IssueState::Open, &["octocat"], &[3], None); + blocked_claimed.labels = vec!["ready-for-agent".into()]; + let blocker = issue(3, IssueState::Open, &[], &[], None); + + let stars = derive(&[ready_claimed, blocked_claimed, blocker]); + + assert_eq!(status_of(&stars, 1), Status::Claimed); + assert!( + stars + .iter() + .find(|star| star.number == 1) + .unwrap() + .ready_for_agent + ); + assert_eq!(status_of(&stars, 2), Status::Claimed); + assert!( + !stars + .iter() + .find(|star| star.number == 2) + .unwrap() + .ready_for_agent + ); + } + #[test] fn removes_self_unknown_and_duplicate_blocker_references() { let stars = derive(&[ diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 861e2f1..02051b3 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -6,4 +6,4 @@ pub mod provider; pub use derive::derive; pub use model::{IssueState, Model, RawIssue, SpaceModel, Star, Status}; -pub use provider::{Provider, ProviderError, RepoRef}; +pub use provider::{Provider, ProviderError, ProviderSnapshot, RepoRef}; diff --git a/crates/core/src/model.rs b/crates/core/src/model.rs index 27b7a27..674aab8 100644 --- a/crates/core/src/model.rs +++ b/crates/core/src/model.rs @@ -17,6 +17,8 @@ pub struct Star { pub parent_issue: Option, pub title: String, pub status: Status, + #[serde(default)] + pub ready_for_agent: bool, pub blocked_by: Vec, pub milestone: Option, pub labels: Vec, @@ -53,6 +55,8 @@ pub struct SpaceModel { pub id: String, pub repo: String, pub name: String, + #[serde(default)] + pub viewer_login: Option, pub stars: Vec, pub synced_at: Option, pub stale: bool, @@ -83,11 +87,13 @@ mod tests { id: "abc".into(), repo: "octocat/hello".into(), name: "hello".into(), + viewer_login: Some("octocat".into()), stars: vec![Star { number: 7, parent_issue: Some(16), title: "Fix login".into(), status: Status::Frontier, + ready_for_agent: false, blocked_by: vec![], milestone: Some("v1".into()), labels: vec!["research".into()], @@ -105,6 +111,10 @@ mod tests { let round_tripped: Model = serde_json::from_str(&json).unwrap(); assert_eq!(round_tripped, model); + assert_eq!( + round_tripped.spaces[0].viewer_login.as_deref(), + Some("octocat") + ); } #[test] @@ -135,6 +145,7 @@ mod tests { let model: Model = serde_json::from_str(old_model).unwrap(); + assert_eq!(model.spaces[0].viewer_login, None); assert_eq!(model.spaces[0].stars[0].parent_issue, None); } } diff --git a/crates/core/src/provider.rs b/crates/core/src/provider.rs index 77948b3..6d59d67 100644 --- a/crates/core/src/provider.rs +++ b/crates/core/src/provider.rs @@ -1,5 +1,20 @@ use crate::RawIssue; +#[derive(Debug, Clone, PartialEq)] +pub struct ProviderSnapshot { + pub viewer_login: Option, + pub issues: Vec, +} + +impl ProviderSnapshot { + pub fn without_viewer(issues: Vec) -> Self { + Self { + viewer_login: None, + issues, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct RepoRef { pub owner: String, @@ -14,7 +29,7 @@ impl RepoRef { #[async_trait::async_trait] pub trait Provider { - async fn fetch(&self, repo: &RepoRef) -> Result, ProviderError>; + async fn fetch(&self, repo: &RepoRef) -> Result; } #[derive(Debug, thiserror::Error)] diff --git a/crates/github/examples/fetch.rs b/crates/github/examples/fetch.rs index d275dd4..50de1d1 100644 --- a/crates/github/examples/fetch.rs +++ b/crates/github/examples/fetch.rs @@ -9,6 +9,10 @@ async fn main() { owner: "teloverge".into(), name: "stellr".into(), }; - let issues = provider.fetch(&repo).await.expect("GitHub fetch failed"); + let issues = provider + .fetch(&repo) + .await + .expect("GitHub fetch failed") + .issues; println!("{}", issues.len()); } diff --git a/crates/github/src/sync.rs b/crates/github/src/sync.rs index 6f50d85..d3fc03d 100644 --- a/crates/github/src/sync.rs +++ b/crates/github/src/sync.rs @@ -1,7 +1,7 @@ use octocrab::{FromResponse, Octocrab}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use stellr_core::{IssueState, Provider, ProviderError, RawIssue, RepoRef}; +use stellr_core::{IssueState, Provider, ProviderError, ProviderSnapshot, RawIssue, RepoRef}; use crate::textref; @@ -9,6 +9,7 @@ const DEFAULT_BASE_URI: &str = "https://api.github.com"; const FETCH_ISSUES_QUERY: &str = r#" query FetchIssues($owner: String!, $name: String!, $cursor: String) { + viewer { login } repository(owner: $owner, name: $name) { issues(first: 100, after: $cursor, states: [OPEN, CLOSED]) { pageInfo { @@ -135,9 +136,10 @@ impl GithubProvider { #[async_trait::async_trait] impl Provider for GithubProvider { - async fn fetch(&self, repo: &RepoRef) -> Result, ProviderError> { + async fn fetch(&self, repo: &RepoRef) -> Result { let mut cursor = None; let mut nodes = Vec::new(); + let mut viewer_login: Option = None; loop { let request = GraphqlRequest { @@ -154,13 +156,25 @@ impl Provider for GithubProvider { let response: GraphqlEnvelope = serde_json::from_value(response) .map_err(|error| ProviderError::Parse(error.to_string()))?; - let connection = response + let data = response .data .ok_or_else(|| ProviderError::Parse("missing data.repository.issues".into())) .and_then(|data| { serde_json::from_value::(data) .map_err(|error| ProviderError::Parse(error.to_string())) - })? + })?; + + match viewer_login.as_deref() { + Some(login) if login != data.viewer.login => { + return Err(ProviderError::Parse( + "viewer login changed during issue pagination".into(), + )); + } + Some(_) => {} + None => viewer_login = Some(data.viewer.login.clone()), + } + + let connection = data .repository .map(|repository| repository.issues) .ok_or_else(|| ProviderError::Parse("missing data.repository.issues".into()))?; @@ -176,7 +190,10 @@ impl Provider for GithubProvider { let mut issues = map_issues(nodes); issues.sort_by_key(|issue| issue.number); - Ok(issues) + Ok(ProviderSnapshot { + viewer_login, + issues, + }) } } @@ -256,9 +273,15 @@ struct GraphqlEnvelope { #[derive(Deserialize)] struct GraphqlData { + viewer: Viewer, repository: Option, } +#[derive(Deserialize)] +struct Viewer { + login: String, +} + #[derive(Deserialize)] struct Repository { issues: IssueConnection, diff --git a/crates/github/tests/sync_test.rs b/crates/github/tests/sync_test.rs index 0bca715..d79a100 100644 --- a/crates/github/tests/sync_test.rs +++ b/crates/github/tests/sync_test.rs @@ -16,8 +16,20 @@ fn page(nodes: Value) -> Value { } fn page_with_pagination(nodes: Value, has_next_page: bool, end_cursor: Option<&str>) -> Value { + page_for_viewer("octocat", nodes, has_next_page, end_cursor) +} + +fn page_for_viewer( + viewer_login: &str, + nodes: Value, + has_next_page: bool, + end_cursor: Option<&str>, +) -> Value { json!({ "data": { + "viewer": { + "login": viewer_login + }, "repository": { "issues": { "pageInfo": { @@ -83,14 +95,68 @@ async fn fetch_follows_pagination_until_the_repository_is_complete() { .await; let provider = GithubProvider::with_base_uri("tok".into(), &server.uri()).unwrap(); - let issues = provider.fetch(&repo()).await.unwrap(); + let result = provider.fetch(&repo()).await.unwrap(); + assert_eq!(result.viewer_login.as_deref(), Some("octocat")); assert_eq!( - issues.iter().map(|issue| issue.number).collect::>(), + result + .issues + .iter() + .map(|issue| issue.number) + .collect::>(), vec![1, 2] ); } +#[tokio::test] +async fn fetch_rejects_missing_viewer_identity_as_a_parse_failure() { + let server = MockServer::start().await; + let mut response = page(json!([])); + response["data"].as_object_mut().unwrap().remove("viewer"); + mount_graphql_response(&server, response).await; + + let provider = GithubProvider::with_base_uri("tok".into(), &server.uri()).unwrap(); + let error = provider.fetch(&repo()).await.unwrap_err(); + + assert!(matches!(error, ProviderError::Parse(_))); + assert!(error.to_string().contains("viewer")); +} + +#[tokio::test] +async fn fetch_rejects_a_viewer_change_during_pagination() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/graphql")) + .and(body_partial_json( + json!({ "variables": { "cursor": "CUR1" } }), + )) + .respond_with(ResponseTemplate::new(200).set_body_json(page_for_viewer( + "hubot", + json!([]), + false, + None, + ))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/graphql")) + .respond_with( + ResponseTemplate::new(200).set_body_json(page_with_pagination( + json!([]), + true, + Some("CUR1"), + )), + ) + .mount(&server) + .await; + + let provider = GithubProvider::with_base_uri("tok".into(), &server.uri()).unwrap(); + let error = provider.fetch(&repo()).await.unwrap_err(); + + assert!(matches!(error, ProviderError::Parse(_))); + assert!(error.to_string().contains("viewer login changed")); +} + #[tokio::test] async fn fetch_maps_a_rejected_token_to_auth() { let server = MockServer::start().await; @@ -218,6 +284,7 @@ async fn mount_graphql_response(server: &MockServer, response: Value) { .and(body_string_contains( "issues(first: 100, after: $cursor, states: [OPEN, CLOSED])", )) + .and(body_string_contains("viewer { login }")) .and(body_string_contains("parent { number }")) .respond_with(ResponseTemplate::new(200).set_body_json(response)) .mount(server) @@ -287,7 +354,7 @@ async fn fetch_maps_complete_issue_shape_and_merges_dependency_sources() { .await; let provider = GithubProvider::with_base_uri("tok".into(), &server.uri()).unwrap(); - let issues = provider.fetch(&repo()).await.unwrap(); + let issues = provider.fetch(&repo()).await.unwrap().issues; assert_eq!(issues[0].parent_issue, None); assert_eq!(issues[2].parent_issue, Some(16)); @@ -410,7 +477,7 @@ async fn fetch_enriches_markdown_relationship_sections() { .await; let provider = GithubProvider::with_base_uri("tok".into(), &server.uri()).unwrap(); - let issues = provider.fetch(&repo()).await.unwrap(); + let issues = provider.fetch(&repo()).await.unwrap().issues; assert_eq!(issues[1].parent_issue, Some(1)); assert_eq!(issues[1].blocked_by, vec![1, 3]); diff --git a/crates/server/src/poll.rs b/crates/server/src/poll.rs index b4ba119..898b238 100644 --- a/crates/server/src/poll.rs +++ b/crates/server/src/poll.rs @@ -109,24 +109,38 @@ async fn sync_space( cache: &Cache, ) -> SpaceModel { match provider.fetch(&entry.repo).await { - Ok(issues) => { + Ok(snapshot) => { let synced_at = Utc::now().timestamp(); // A successful provider sync is fresh even if its fallback cache cannot be updated. let _ = cache.store( &entry.repo, &Snapshot { - issues: issues.clone(), + issues: snapshot.issues.clone(), synced_at, }, ); - model(entry, issues, Some(synced_at), false, None) + model( + entry, + snapshot.issues, + snapshot.viewer_login, + Some(synced_at), + false, + None, + ) } Err(error) => { let snapshot = cache.load(&entry.repo); let (issues, synced_at) = snapshot .map(|snapshot| (snapshot.issues, Some(snapshot.synced_at))) .unwrap_or_default(); - model(entry, issues, synced_at, true, Some(error.to_string())) + model( + entry, + issues, + None, + synced_at, + true, + Some(error.to_string()), + ) } } } @@ -134,6 +148,7 @@ async fn sync_space( fn model( entry: &SpaceEntry, issues: Vec, + viewer_login: Option, synced_at: Option, stale: bool, error: Option, @@ -142,6 +157,7 @@ fn model( id: entry.id.clone(), repo: entry.repo.slug(), name: entry.repo.name.clone(), + viewer_login, stars: derive(&issues), synced_at, stale, diff --git a/crates/server/tests/api_test.rs b/crates/server/tests/api_test.rs index 070a993..add220c 100644 --- a/crates/server/tests/api_test.rs +++ b/crates/server/tests/api_test.rs @@ -8,7 +8,9 @@ use std::{ use futures_util::{SinkExt, StreamExt}; use serde_json::json; -use stellr_core::{IssueState, Model, Provider, ProviderError, RawIssue, RepoRef, SpaceModel}; +use stellr_core::{ + IssueState, Model, Provider, ProviderError, ProviderSnapshot, RawIssue, RepoRef, SpaceModel, +}; use stellr_github::cache::{Cache, Snapshot}; use stellr_server::{ poll::spawn_poller, @@ -65,6 +67,7 @@ fn model_with_space(id: &str) -> Model { id: id.into(), repo: "owner/repo".into(), name: "repo".into(), + viewer_login: None, stars: vec![], synced_at: None, stale: false, @@ -139,11 +142,11 @@ async fn embedded_ui_does_not_mask_unknown_api_paths() { assert_eq!(response.status(), reqwest::StatusCode::NOT_FOUND); } -struct StubProvider(Vec); +struct StubProvider(ProviderSnapshot); #[async_trait::async_trait] impl Provider for StubProvider { - async fn fetch(&self, _repo: &RepoRef) -> Result, ProviderError> { + async fn fetch(&self, _repo: &RepoRef) -> Result { Ok(self.0.clone()) } } @@ -152,7 +155,7 @@ struct FailingProvider; #[async_trait::async_trait] impl Provider for FailingProvider { - async fn fetch(&self, _repo: &RepoRef) -> Result, ProviderError> { + async fn fetch(&self, _repo: &RepoRef) -> Result { Err(ProviderError::Http("offline".into())) } } @@ -161,11 +164,11 @@ struct SequenceProvider(AtomicUsize); #[async_trait::async_trait] impl Provider for SequenceProvider { - async fn fetch(&self, _repo: &RepoRef) -> Result, ProviderError> { + async fn fetch(&self, _repo: &RepoRef) -> Result { if self.0.fetch_add(1, Ordering::SeqCst) == 0 { - return Ok(vec![]); + return Ok(ProviderSnapshot::without_viewer(vec![])); } - Ok(vec![RawIssue { + Ok(ProviderSnapshot::without_viewer(vec![RawIssue { number: 9, parent_issue: None, title: "Arrived on the second tick".into(), @@ -176,7 +179,7 @@ impl Provider for SequenceProvider { labels: vec![], blocked_by: vec![], url: "https://github.com/o/r/issues/9".into(), - }]) + }])) } } @@ -192,18 +195,21 @@ async fn add_repo_space_immediately_populates_the_model() { }); let poller = spawn_poller( state.clone(), - Arc::new(StubProvider(vec![RawIssue { - number: 1, - parent_issue: None, - title: "Ready work".into(), - body: String::new(), - state: IssueState::Open, - assignees: vec![], - milestone: None, - labels: vec![], - blocked_by: vec![], - url: "https://github.com/o/r/issues/1".into(), - }])), + Arc::new(StubProvider(ProviderSnapshot { + viewer_login: Some("octocat".into()), + issues: vec![RawIssue { + number: 1, + parent_issue: None, + title: "Ready work".into(), + body: String::new(), + state: IssueState::Open, + assignees: vec!["OctoCat".into()], + milestone: None, + labels: vec!["ready-for-agent".into()], + blocked_by: vec![], + url: "https://github.com/o/r/issues/1".into(), + }], + })), Cache::new(directory.path().join("cache")), Duration::from_secs(60), ); @@ -250,7 +256,9 @@ async fn add_repo_space_immediately_populates_the_model() { assert_eq!(model.spaces[0].id, "o-r"); assert_eq!(model.spaces[0].repo, "o/r"); + assert_eq!(model.spaces[0].viewer_login.as_deref(), Some("octocat")); assert_eq!(model.spaces[0].stars[0].number, 1); + assert!(model.spaces[0].stars[0].ready_for_agent); poller.abort(); } @@ -354,7 +362,7 @@ async fn successful_sync_stays_fresh_when_the_cache_cannot_be_written() { }); let poller = spawn_poller( state, - Arc::new(StubProvider(vec![])), + Arc::new(StubProvider(ProviderSnapshot::without_viewer(vec![]))), Cache::new(cache_root), Duration::from_secs(60), ); diff --git a/crates/server/tests/focus_polling_test.rs b/crates/server/tests/focus_polling_test.rs index 2d0e869..77dffd5 100644 --- a/crates/server/tests/focus_polling_test.rs +++ b/crates/server/tests/focus_polling_test.rs @@ -3,7 +3,7 @@ use std::sync::{ atomic::{AtomicUsize, Ordering}, }; -use stellr_core::{Model, Provider, ProviderError, RawIssue, RepoRef}; +use stellr_core::{Model, Provider, ProviderError, ProviderSnapshot, RepoRef}; use stellr_github::cache::Cache; use stellr_server::{ poll::{PollingControl, spawn_controlled_poller}, @@ -15,9 +15,9 @@ struct CountingProvider(Arc); #[async_trait::async_trait] impl Provider for CountingProvider { - async fn fetch(&self, _repo: &RepoRef) -> Result, ProviderError> { + async fn fetch(&self, _repo: &RepoRef) -> Result { self.0.fetch_add(1, Ordering::SeqCst); - Ok(vec![]) + Ok(ProviderSnapshot::without_viewer(vec![])) } } diff --git a/web/src/lib/model.ts b/web/src/lib/model.ts index e3f7b3f..830cf1e 100644 --- a/web/src/lib/model.ts +++ b/web/src/lib/model.ts @@ -5,6 +5,7 @@ export interface Star { parent_issue: number | null title: string status: Status + ready_for_agent?: boolean blocked_by: number[] milestone: string | null labels: string[] @@ -17,6 +18,7 @@ export interface SpaceModel { id: string repo: string name: string + viewer_login?: string | null stars: Star[] synced_at: number | null stale: boolean diff --git a/web/src/lib/starmap/adapt.test.ts b/web/src/lib/starmap/adapt.test.ts index 54b2eb2..bd11500 100644 --- a/web/src/lib/starmap/adapt.test.ts +++ b/web/src/lib/starmap/adapt.test.ts @@ -67,4 +67,22 @@ describe('toRendererModel', () => { false, ]) }) + + it('matches viewer ownership case-insensitively without redefining claimed', () => { + const input = space() as SpaceModel & { viewer_login: string } + input.viewer_login = 'octocat' + input.stars[2].assignees = ['OctoCat'] + Object.assign(input.stars[2], { ready_for_agent: true }) + input.stars[3].assignees = ['hubot'] + Object.assign(input.stars[3], { ready_for_agent: true }) + + const model = toRendererModel(input) + + expect(model[2]).toMatchObject({ + status: 'claimed', + assignedToViewer: true, + readyForAgent: true, + }) + expect(model[3]).toMatchObject({ assignedToViewer: false }) + }) }) diff --git a/web/src/lib/starmap/adapt.ts b/web/src/lib/starmap/adapt.ts index f8b3c05..4d45228 100644 --- a/web/src/lib/starmap/adapt.ts +++ b/web/src/lib/starmap/adapt.ts @@ -2,17 +2,24 @@ import type { SpaceModel } from '../model' import type { Ticket } from './model' 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: + const viewerLogin = space.viewer_login?.toLowerCase() + return space.stars.map((star) => { + const legacyReadyForAgent = star.status === 'frontier' && - star.labels.some((label) => label.toLowerCase() === 'ready-for-agent'), - })) + star.labels.some((label) => label.toLowerCase() === 'ready-for-agent') + 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: star.ready_for_agent ?? legacyReadyForAgent, + assignedToViewer: + viewerLogin !== undefined && + star.assignees.some((assignee) => assignee.toLowerCase() === viewerLogin), + } + }) } diff --git a/web/src/lib/starmap/core-visual.test.ts b/web/src/lib/starmap/core-visual.test.ts index cee1556..5b29df2 100644 --- a/web/src/lib/starmap/core-visual.test.ts +++ b/web/src/lib/starmap/core-visual.test.ts @@ -67,6 +67,14 @@ const READY_CHILD: Ticket = { parentIssue: 99, readyForAgent: true, } +const MY_NEXT: Ticket = { + ...CLAIMED, + num: 10, + slug: '10', + title: 'My next work', + readyForAgent: true, + assignedToViewer: true, +} function recordingContext(): { ctx: Record @@ -178,6 +186,14 @@ describe('issue core visual grammar', () => { expect(fills.some((fill) => fill.style === '#000')).toBe(false) }) + it('paints My next work as a solid blue core without a black disk', () => { + const { fills, strokes } = paint(MY_NEXT) + + expect(fills[1]).toEqual({ style: '#8ad8ff', arc: { radius: 11 } }) + expect(fills.some((fill) => fill.style === '#000')).toBe(false) + expect(strokes.some((stroke) => stroke.style === 'rgba(138,216,255,0.95)')).toBe(false) + }) + it('paints every incomplete state with its unchanged glow, black disk, and status rim', () => { for (const expected of [ { diff --git a/web/src/lib/starmap/model.ts b/web/src/lib/starmap/model.ts index dcbd1b1..bb3ccfd 100644 --- a/web/src/lib/starmap/model.ts +++ b/web/src/lib/starmap/model.ts @@ -18,6 +18,7 @@ export interface Ticket { parentIssue: number | null frontier: boolean readyForAgent?: boolean + assignedToViewer?: boolean } export interface Map { diff --git a/web/src/lib/starmap/starmap.ts b/web/src/lib/starmap/starmap.ts index 30b41fe..e6bea95 100644 --- a/web/src/lib/starmap/starmap.ts +++ b/web/src/lib/starmap/starmap.ts @@ -105,10 +105,11 @@ type Box = LabelBox // lowest, which is what array order gave us. const LABEL_PRIORITY: Record = { frontier: 0, - claimed: 1, - resolved: 2, - blocked: 3, - out_of_scope: 4, + my_next: 1, + claimed: 2, + resolved: 3, + blocked: 4, + out_of_scope: 5, } // A star just off-screen can still own a label that reaches back on-screen, so @@ -1114,7 +1115,12 @@ export class StarMap { g.arc(x, y, cr + 4, 0, TAU) g.stroke() } - if (n.vstate === 'resolved') { + if (n.vstate === 'my_next') { + g.fillStyle = c.core + g.beginPath() + g.arc(x, y, cr, 0, TAU) + g.fill() + } else 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)) diff --git a/web/src/lib/starmap/theme.ts b/web/src/lib/starmap/theme.ts index 900614d..e1197f8 100644 --- a/web/src/lib/starmap/theme.ts +++ b/web/src/lib/starmap/theme.ts @@ -18,6 +18,7 @@ import type { Ticket } from './model' export type VisualState = | 'resolved' | 'frontier' + | 'my_next' | 'claimed' | 'blocked' | 'out_of_scope' @@ -41,6 +42,7 @@ export interface StarStyle { export const STAR: Record = { resolved: { core: '#b9d6c4', glow: '#5b9077', r: 5.4, gr: 24 }, frontier: { core: '#8ad8ff', glow: '#2f9be0', r: 8.1, gr: 49 }, + my_next: { core: '#8ad8ff', glow: '#2f9be0', r: 8.8, gr: 49 }, claimed: { core: '#ffd873', glow: '#ffb020', r: 7.2, gr: 36 }, blocked: { core: '#e2c3c3', glow: '#9a6f6f', r: 4.5, gr: 20 }, out_of_scope: { core: '#948da4', glow: '#6b6478', r: 4.5, gr: 18 }, @@ -49,6 +51,7 @@ export const STAR: Record = { export const LABEL: Record = { resolved: '#a2c1ac', frontier: '#b3e5ff', + my_next: '#b3e5ff', claimed: '#ffe6a0', blocked: '#d0b3b3', out_of_scope: '#a89fb2', @@ -58,7 +61,10 @@ export const LABEL: Record = { // 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 // exists is this at-a-glance read. -export function visualState(t: Pick): VisualState { +export function visualState( + t: Pick, +): VisualState { + if (t.assignedToViewer && t.readyForAgent) return 'my_next' switch (t.status) { case 'resolved': return 'resolved' From cc95281debe1612bb81e335f319231b2d86621a3 Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:31:42 -0500 Subject: [PATCH 03/17] fix: keep cached ownership account-safe (#92) --- crates/app/src/runtime.rs | 36 ++++++++-- crates/app/tests/provider_activation_test.rs | 62 +++++++++++++++++ crates/core/src/provider.rs | 4 ++ crates/github/src/cache.rs | 4 ++ crates/server/src/poll.rs | 17 ++++- crates/server/tests/api_test.rs | 72 ++++++++++++++++++++ web/src/lib/starmap/adapt.test.ts | 14 ++++ 7 files changed, 202 insertions(+), 7 deletions(-) diff --git a/crates/app/src/runtime.rs b/crates/app/src/runtime.rs index ac92345..337de7d 100644 --- a/crates/app/src/runtime.rs +++ b/crates/app/src/runtime.rs @@ -1,4 +1,14 @@ -use std::{io, net::SocketAddr, num::NonZeroU64, path::PathBuf, sync::Arc, time::Duration}; +use std::{ + io, + net::SocketAddr, + num::NonZeroU64, + path::PathBuf, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, + time::Duration, +}; use stellr_core::{Model, Provider, ProviderError, ProviderSnapshot, RepoRef}; use stellr_github::cache::Cache; @@ -17,25 +27,43 @@ use tokio::{ #[derive(Clone)] pub struct ProviderSlot { current: Arc>>, + generation: Arc, + confirmed_generation: Arc, } impl ProviderSlot { pub fn new(provider: Arc) -> Self { Self { current: Arc::new(RwLock::new(provider)), + generation: Arc::new(AtomicU64::new(0)), + confirmed_generation: Arc::new(AtomicU64::new(0)), } } pub async fn replace(&self, provider: Arc) { - *self.current.write().await = provider; + let mut current = self.current.write().await; + *current = provider; + self.generation.fetch_add(1, Ordering::AcqRel); } } #[async_trait::async_trait] impl Provider for ProviderSlot { async fn fetch(&self, repo: &RepoRef) -> Result { - let provider = self.current.read().await.clone(); - provider.fetch(repo).await + let (provider, generation) = { + let current = self.current.read().await; + (current.clone(), self.generation.load(Ordering::Acquire)) + }; + let result = provider.fetch(repo).await; + if result.is_ok() && self.generation.load(Ordering::Acquire) == generation { + self.confirmed_generation + .store(generation, Ordering::Release); + } + result + } + + fn allows_cached_viewer_identity(&self) -> bool { + self.confirmed_generation.load(Ordering::Acquire) == self.generation.load(Ordering::Acquire) } } diff --git a/crates/app/tests/provider_activation_test.rs b/crates/app/tests/provider_activation_test.rs index 53a89b8..b28cc21 100644 --- a/crates/app/tests/provider_activation_test.rs +++ b/crates/app/tests/provider_activation_test.rs @@ -38,3 +38,65 @@ async fn replacing_the_provider_activates_it_in_the_current_process() { assert_eq!(slot.fetch(&repo).await.unwrap().issues, vec![]); } + +struct DelayedSuccess { + started: Arc, + release: Arc, +} + +#[async_trait::async_trait] +impl Provider for DelayedSuccess { + async fn fetch(&self, _repo: &RepoRef) -> Result { + self.started.notify_one(); + self.release.notified().await; + Ok(ProviderSnapshot { + viewer_login: Some("previous-account".into()), + issues: vec![], + }) + } +} + +#[tokio::test] +async fn replacement_suppresses_cached_identity_until_the_new_provider_succeeds() { + let slot = ProviderSlot::new(Arc::new(Active)); + let repo = RepoRef { + owner: "teloverge".into(), + name: "stellr".into(), + }; + assert!(slot.allows_cached_viewer_identity()); + + slot.replace(Arc::new(SignedOut)).await; + assert!(!slot.allows_cached_viewer_identity()); + assert!(slot.fetch(&repo).await.is_err()); + assert!(!slot.allows_cached_viewer_identity()); + + slot.replace(Arc::new(Active)).await; + assert!(!slot.allows_cached_viewer_identity()); + slot.fetch(&repo).await.unwrap(); + assert!(slot.allows_cached_viewer_identity()); +} + +#[tokio::test] +async fn an_old_in_flight_success_cannot_confirm_a_replacement_generation() { + let started = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let slot = ProviderSlot::new(Arc::new(DelayedSuccess { + started: started.clone(), + release: release.clone(), + })); + let repo = RepoRef { + owner: "teloverge".into(), + name: "stellr".into(), + }; + let started_wait = started.notified(); + let fetching_slot = slot.clone(); + let fetching_repo = repo.clone(); + let fetch = tokio::spawn(async move { fetching_slot.fetch(&fetching_repo).await }); + started_wait.await; + + slot.replace(Arc::new(SignedOut)).await; + release.notify_one(); + fetch.await.unwrap().unwrap(); + + assert!(!slot.allows_cached_viewer_identity()); +} diff --git a/crates/core/src/provider.rs b/crates/core/src/provider.rs index 6d59d67..9758a12 100644 --- a/crates/core/src/provider.rs +++ b/crates/core/src/provider.rs @@ -30,6 +30,10 @@ impl RepoRef { #[async_trait::async_trait] pub trait Provider { async fn fetch(&self, repo: &RepoRef) -> Result; + + fn allows_cached_viewer_identity(&self) -> bool { + true + } } #[derive(Debug, thiserror::Error)] diff --git a/crates/github/src/cache.rs b/crates/github/src/cache.rs index ae36b05..07c471c 100644 --- a/crates/github/src/cache.rs +++ b/crates/github/src/cache.rs @@ -13,6 +13,8 @@ static NEXT_ARTIFACT_ID: AtomicU64 = AtomicU64::new(0); #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Snapshot { + #[serde(default)] + pub viewer_login: Option, pub issues: Vec, pub synced_at: i64, } @@ -253,6 +255,7 @@ mod tests { fn snapshot(title: &str, synced_at: i64) -> Snapshot { Snapshot { + viewer_login: Some("octocat".into()), issues: vec![RawIssue { number: 1, parent_issue: None, @@ -319,6 +322,7 @@ mod tests { let snapshot = cache.load(&repo).unwrap(); + assert_eq!(snapshot.viewer_login, None); assert_eq!(snapshot.issues[0].parent_issue, None); } diff --git a/crates/server/src/poll.rs b/crates/server/src/poll.rs index 898b238..9bbfc2f 100644 --- a/crates/server/src/poll.rs +++ b/crates/server/src/poll.rs @@ -115,6 +115,7 @@ async fn sync_space( let _ = cache.store( &entry.repo, &Snapshot { + viewer_login: snapshot.viewer_login.clone(), issues: snapshot.issues.clone(), synced_at, }, @@ -130,13 +131,23 @@ async fn sync_space( } Err(error) => { let snapshot = cache.load(&entry.repo); - let (issues, synced_at) = snapshot - .map(|snapshot| (snapshot.issues, Some(snapshot.synced_at))) + let (issues, cached_viewer_login, synced_at) = snapshot + .map(|snapshot| { + ( + snapshot.issues, + snapshot.viewer_login, + Some(snapshot.synced_at), + ) + }) .unwrap_or_default(); + let viewer_login = provider + .allows_cached_viewer_identity() + .then_some(cached_viewer_login) + .flatten(); model( entry, issues, - None, + viewer_login, synced_at, true, Some(error.to_string()), diff --git a/crates/server/tests/api_test.rs b/crates/server/tests/api_test.rs index add220c..d774cb2 100644 --- a/crates/server/tests/api_test.rs +++ b/crates/server/tests/api_test.rs @@ -160,6 +160,19 @@ impl Provider for FailingProvider { } } +struct UnconfirmedFailingProvider; + +#[async_trait::async_trait] +impl Provider for UnconfirmedFailingProvider { + async fn fetch(&self, _repo: &RepoRef) -> Result { + Err(ProviderError::Http("replacement offline".into())) + } + + fn allows_cached_viewer_identity(&self) -> bool { + false + } +} + struct SequenceProvider(AtomicUsize); #[async_trait::async_trait] @@ -274,6 +287,7 @@ async fn failed_sync_publishes_the_cached_model_as_stale_with_the_error() { .store( &repo, &Snapshot { + viewer_login: Some("octocat".into()), issues: vec![RawIssue { number: 7, parent_issue: None, @@ -329,6 +343,7 @@ async fn failed_sync_publishes_the_cached_model_as_stale_with_the_error() { .expect("failed sync should still publish the cached model"); assert_eq!(model.spaces[0].stars[0].number, 7); + assert_eq!(model.spaces[0].viewer_login.as_deref(), Some("octocat")); assert_eq!(model.spaces[0].synced_at, Some(1_753_000_000)); assert!(model.spaces[0].stale); assert_eq!( @@ -338,6 +353,63 @@ async fn failed_sync_publishes_the_cached_model_as_stale_with_the_error() { poller.abort(); } +#[tokio::test] +async fn failed_unconfirmed_provider_keeps_cached_issues_but_suppresses_cached_viewer() { + let directory = tempfile::tempdir().unwrap(); + let repo = RepoRef { + owner: "o".into(), + name: "r".into(), + }; + let cache = Cache::new(directory.path().join("cache")); + cache + .store( + &repo, + &Snapshot { + viewer_login: Some("previous-account".into()), + issues: vec![RawIssue { + number: 7, + parent_issue: None, + title: "Cached work".into(), + body: String::new(), + state: IssueState::Open, + assignees: vec!["previous-account".into()], + milestone: None, + labels: vec!["ready-for-agent".into()], + blocked_by: vec![], + url: "https://github.com/o/r/issues/7".into(), + }], + synced_at: 1_753_000_000, + }, + ) + .unwrap(); + let mut spaces = SpaceStore::load(directory.path().join("spaces.toml")); + spaces.add(SpaceEntry::new(repo, None)).unwrap(); + let (hub, mut receiver) = tokio::sync::watch::channel(Model { spaces: vec![] }); + let state = Arc::new(AppState { + hub, + token: None, + spaces: tokio::sync::Mutex::new(spaces), + refresh: Arc::new(tokio::sync::Notify::new()), + }); + let poller = spawn_poller( + state, + Arc::new(UnconfirmedFailingProvider), + cache, + Duration::from_secs(60), + ); + + tokio::time::timeout(Duration::from_secs(1), receiver.changed()) + .await + .expect("the stale snapshot should publish") + .expect("the model hub should remain open"); + let model = receiver.borrow_and_update().clone(); + + assert_eq!(model.spaces[0].viewer_login, None); + assert_eq!(model.spaces[0].stars[0].number, 7); + assert!(model.spaces[0].stale); + poller.abort(); +} + #[tokio::test] async fn successful_sync_stays_fresh_when_the_cache_cannot_be_written() { let directory = tempfile::tempdir().unwrap(); diff --git a/web/src/lib/starmap/adapt.test.ts b/web/src/lib/starmap/adapt.test.ts index bd11500..26cdefa 100644 --- a/web/src/lib/starmap/adapt.test.ts +++ b/web/src/lib/starmap/adapt.test.ts @@ -85,4 +85,18 @@ describe('toRendererModel', () => { }) expect(model[3]).toMatchObject({ assignedToViewer: false }) }) + + it('never guesses ownership when viewer identity is unavailable', () => { + const input = space() + input.stars[2].assignees = ['previous-account'] + Object.assign(input.stars[2], { ready_for_agent: true }) + + const model = toRendererModel(input) + + expect(model[2]).toMatchObject({ + status: 'claimed', + assignedToViewer: false, + readyForAgent: true, + }) + }) }) From 3fa5a7fb6438171f85c1a5ccf096593a47cace6a Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:39:43 -0500 Subject: [PATCH 04/17] feat: derive complete work priority hierarchy (#93) --- crates/core/src/derive.rs | 3 + crates/core/src/model.rs | 4 ++ web/src/lib/model.ts | 1 + web/src/lib/starmap/adapt.test.ts | 3 +- web/src/lib/starmap/adapt.ts | 1 + web/src/lib/starmap/core-visual.test.ts | 87 ++++++++++++++++------- web/src/lib/starmap/model.ts | 1 + web/src/lib/starmap/priority.test.ts | 69 ++++++++++++++++++ web/src/lib/starmap/priority.ts | 30 ++++++++ web/src/lib/starmap/session.ts | 2 +- web/src/lib/starmap/starmap.test.ts | 2 +- web/src/lib/starmap/starmap.ts | 49 +++++-------- web/src/lib/starmap/theme.ts | 94 ++++++------------------- 13 files changed, 213 insertions(+), 133 deletions(-) create mode 100644 web/src/lib/starmap/priority.test.ts create mode 100644 web/src/lib/starmap/priority.ts diff --git a/crates/core/src/derive.rs b/crates/core/src/derive.rs index 4c11668..48ae56a 100644 --- a/crates/core/src/derive.rs +++ b/crates/core/src/derive.rs @@ -41,6 +41,7 @@ pub fn derive(issues: &[RawIssue]) -> Vec { title: issue.title.clone(), status, ready_for_agent, + blocked: has_open_blocker, blocked_by, milestone: issue.milestone.clone(), labels: issue.labels.clone(), @@ -134,6 +135,8 @@ mod tests { .unwrap() .ready_for_agent ); + assert!(!stars.iter().find(|star| star.number == 1).unwrap().blocked); + assert!(stars.iter().find(|star| star.number == 2).unwrap().blocked); } #[test] diff --git a/crates/core/src/model.rs b/crates/core/src/model.rs index 674aab8..75597d7 100644 --- a/crates/core/src/model.rs +++ b/crates/core/src/model.rs @@ -19,6 +19,8 @@ pub struct Star { pub status: Status, #[serde(default)] pub ready_for_agent: bool, + #[serde(default)] + pub blocked: bool, pub blocked_by: Vec, pub milestone: Option, pub labels: Vec, @@ -94,6 +96,7 @@ mod tests { title: "Fix login".into(), status: Status::Frontier, ready_for_agent: false, + blocked: false, blocked_by: vec![], milestone: Some("v1".into()), labels: vec!["research".into()], @@ -147,5 +150,6 @@ mod tests { assert_eq!(model.spaces[0].viewer_login, None); assert_eq!(model.spaces[0].stars[0].parent_issue, None); + assert!(!model.spaces[0].stars[0].blocked); } } diff --git a/web/src/lib/model.ts b/web/src/lib/model.ts index 830cf1e..9bd3100 100644 --- a/web/src/lib/model.ts +++ b/web/src/lib/model.ts @@ -6,6 +6,7 @@ export interface Star { title: string status: Status ready_for_agent?: boolean + blocked?: boolean blocked_by: number[] milestone: string | null labels: string[] diff --git a/web/src/lib/starmap/adapt.test.ts b/web/src/lib/starmap/adapt.test.ts index 26cdefa..c6a5ce5 100644 --- a/web/src/lib/starmap/adapt.test.ts +++ b/web/src/lib/starmap/adapt.test.ts @@ -72,7 +72,7 @@ describe('toRendererModel', () => { const input = space() as SpaceModel & { viewer_login: string } input.viewer_login = 'octocat' input.stars[2].assignees = ['OctoCat'] - Object.assign(input.stars[2], { ready_for_agent: true }) + Object.assign(input.stars[2], { ready_for_agent: true, blocked: true }) input.stars[3].assignees = ['hubot'] Object.assign(input.stars[3], { ready_for_agent: true }) @@ -82,6 +82,7 @@ describe('toRendererModel', () => { status: 'claimed', assignedToViewer: true, readyForAgent: true, + blocked: true, }) expect(model[3]).toMatchObject({ assignedToViewer: false }) }) diff --git a/web/src/lib/starmap/adapt.ts b/web/src/lib/starmap/adapt.ts index 4d45228..d57c74a 100644 --- a/web/src/lib/starmap/adapt.ts +++ b/web/src/lib/starmap/adapt.ts @@ -17,6 +17,7 @@ export function toRendererModel(space: SpaceModel): Ticket[] { parentIssue: star.parent_issue, frontier: star.status === 'frontier', readyForAgent: star.ready_for_agent ?? legacyReadyForAgent, + blocked: star.blocked ?? star.status === 'blocked', assignedToViewer: viewerLogin !== undefined && star.assignees.some((assignee) => assignee.toLowerCase() === viewerLogin), diff --git a/web/src/lib/starmap/core-visual.test.ts b/web/src/lib/starmap/core-visual.test.ts index 5b29df2..8db251f 100644 --- a/web/src/lib/starmap/core-visual.test.ts +++ b/web/src/lib/starmap/core-visual.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { StarMap } from './starmap' import type { Ticket } from './model' +import type { SessionState } from './session' type Arc = { radius: number } type Gradient = { kind: 'radial-gradient'; stops: Array<{ at: number; color: string }> } @@ -75,6 +76,20 @@ const MY_NEXT: Ticket = { readyForAgent: true, assignedToViewer: true, } +const MY_FUTURE: Ticket = { + ...CLAIMED, + num: 11, + slug: '11', + title: 'My future work', + assignedToViewer: true, +} +const AVAILABLE_NEXT: Ticket = { + ...FRONTIER, + num: 12, + slug: '12', + title: 'Available next', + readyForAgent: true, +} function recordingContext(): { ctx: Record @@ -150,7 +165,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, + session: SessionState | null = null, + ): { fills: Fill[]; strokes: Stroke[] } { const recording = recordingContext() getContext.mockReturnValue(recording.ctx as never) const host = document.createElement('div') @@ -159,7 +178,7 @@ describe('issue core visual grammar', () => { document.body.appendChild(host) const map = new StarMap() map.mount(host) - map.setModel([ticket], {}, currentIssue) + map.setModel([ticket], session ? { [ticket.num]: session } : {}, currentIssue) const frame = frames.pop() frames = [] frame?.(0) @@ -194,39 +213,56 @@ describe('issue core visual grammar', () => { expect(strokes.some((stroke) => stroke.style === 'rgba(138,216,255,0.95)')).toBe(false) }) - it('paints every incomplete state with its unchanged glow, black disk, and status rim', () => { + it('paints doing now and team work as distinct solid cores', () => { + const doing = paint(BLOCKED, BLOCKED.num) + expect(doing.fills[1]).toEqual({ style: '#ffd873', arc: { radius: 12 } }) + expect(doing.fills.some((fill) => fill.style === '#000')).toBe(false) + + const team = paint(CLAIMED) + expect(team.fills[1]).toEqual({ style: '#b9a7ee', arc: { radius: 9 } }) + expect(team.fills.some((fill) => fill.style === '#000')).toBe(false) + }) + + it('uses the doing-now amber core size when a session needs attention', () => { + const attention = paint(CLAIMED, null, 'blocked') + + expect(attention.fills[1]).toEqual({ style: '#ffd873', arc: { radius: 12 } }) + expect(attention.fills.some((fill) => fill.style === '#000')).toBe(false) + }) + + it('paints future, available, planning, and not-planned work as hollow priority cores', () => { for (const expected of [ { - ticket: FRONTIER, + ticket: MY_FUTURE, glow: [ - { at: 0, color: 'rgba(47,155,224,0.765)' }, - { at: 0.4, color: 'rgba(47,155,224,0.198)' }, + { at: 0, color: 'rgba(47,155,224,0.85)' }, + { at: 0.4, color: 'rgba(47,155,224,0.22)' }, { at: 1, color: 'rgba(47,155,224,0)' }, ], - blackRadius: 10.125, + blackRadius: 10, rim: 'rgba(138,216,255,0.95)', - width: 3.24, + width: 3.2, }, { - ticket: CLAIMED, + ticket: AVAILABLE_NEXT, 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)' }, + { at: 0, color: 'rgba(59,159,104,0.85)' }, + { at: 0.4, color: 'rgba(59,159,104,0.22)' }, + { at: 1, color: 'rgba(59,159,104,0)' }, ], - blackRadius: 9, - rim: 'rgba(255,216,115,0.95)', - width: 2.88, + blackRadius: 10, + rim: 'rgba(142,215,172,0.95)', + width: 3.2, }, { 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)' }, + { at: 0, color: 'rgba(113,104,132,0.85)' }, + { at: 0.4, color: 'rgba(113,104,132,0.22)' }, + { at: 1, color: 'rgba(113,104,132,0)' }, ], blackRadius: 5.625, - rim: 'rgba(226,195,195,0.95)', + rim: 'rgba(170,160,189,0.95)', width: 2.2, }, { @@ -258,15 +294,14 @@ describe('issue core visual grammar', () => { } }) - it('keeps both CURRENT rings around an incomplete issue after its hollow core', () => { + it('keeps both CURRENT rings around the solid doing-now core', () => { const { fills, strokes } = paint(BLOCKED, BLOCKED.num) expect(fills).toHaveLength(2) - expect(fills[1]).toEqual({ style: '#000', arc: { radius: 5.625 } }) + expect(fills[1]).toEqual({ style: '#ffd873', arc: { radius: 12 } }) 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 }, + { style: 'rgba(255,255,255,0.95)', lineWidth: 2, radius: 20 }, + { style: 'rgba(255,255,255,0.55)', lineWidth: 1, radius: 25 }, ]) }) @@ -278,7 +313,7 @@ describe('issue core visual grammar', () => { expect(relationshipRims).toHaveLength(1) expect(relationshipRims[0].arc!.radius).toBeGreaterThan(blackCore.arc!.radius) expect(incomplete.strokes).toContainEqual({ - style: 'rgba(226,195,195,0.95)', + style: 'rgba(170,160,189,0.95)', lineWidth: 2.2, arc: { radius: 4.525 }, }) @@ -293,7 +328,7 @@ describe('issue core visual grammar', () => { const readyRelationshipRadius = ready.strokes[readyRelationshipIndex].arc!.radius const readyEmphasisIndexes = ready.strokes .map((stroke, index) => - stroke.style === 'rgba(138,216,255,0.95)' && + stroke.style === 'rgba(142,215,172,0.95)' && (stroke.arc?.radius ?? 0) > readyRelationshipRadius ? index : -1, diff --git a/web/src/lib/starmap/model.ts b/web/src/lib/starmap/model.ts index bb3ccfd..5ca2f70 100644 --- a/web/src/lib/starmap/model.ts +++ b/web/src/lib/starmap/model.ts @@ -19,6 +19,7 @@ export interface Ticket { frontier: boolean readyForAgent?: boolean assignedToViewer?: boolean + blocked?: boolean } export interface Map { diff --git a/web/src/lib/starmap/priority.test.ts b/web/src/lib/starmap/priority.test.ts new file mode 100644 index 0000000..4ff73d8 --- /dev/null +++ b/web/src/lib/starmap/priority.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import { deriveWorkPriority } from './priority' +import type { Ticket } from './model' + +function ticket(overrides: Partial = {}): Ticket { + return { + num: 1, + slug: '1', + title: 'Work', + type: 'issue', + status: 'frontier', + blockedBy: [], + parentIssue: null, + frontier: true, + readyForAgent: false, + assignedToViewer: false, + ...overrides, + } +} + +describe('work priority hierarchy', () => { + it('puts session trouble ahead of current and implementing work', () => { + const t = ticket({ num: 7, status: 'claimed', assignedToViewer: true }) + expect(deriveWorkPriority(t, 'blocked', 7)).toBe('attention') + expect(deriveWorkPriority(t, 'dead', 7)).toBe('attention') + }) + + it('treats current or actively implementing work as doing now', () => { + expect(deriveWorkPriority(ticket({ num: 7 }), null, 7)).toBe('doing_now') + expect(deriveWorkPriority(ticket(), 'implementing', null)).toBe('doing_now') + }) + + it('orders owned, available, and team work by the viewer hierarchy', () => { + expect( + deriveWorkPriority( + ticket({ status: 'claimed', assignedToViewer: true, readyForAgent: true }), + null, + null, + ), + ).toBe('my_next') + expect( + deriveWorkPriority( + ticket({ status: 'claimed', assignedToViewer: true, blocked: true }), + null, + null, + ), + ).toBe('my_future') + expect(deriveWorkPriority(ticket({ readyForAgent: true }), null, null)).toBe( + 'available_next', + ) + expect(deriveWorkPriority(ticket({ status: 'claimed' }), null, null)).toBe('team_work') + expect( + deriveWorkPriority(ticket({ status: 'claimed', readyForAgent: true }), null, null), + ).toBe('team_work') + }) + + it('keeps remaining open work quiet and separates both closure reasons', () => { + expect(deriveWorkPriority(ticket({ status: 'blocked' }), null, null)).toBe('planning') + expect(deriveWorkPriority(ticket({ status: 'resolved' }), null, null)).toBe('resolved') + expect(deriveWorkPriority(ticket({ status: 'out_of_scope' }), null, null)).toBe( + 'out_of_scope', + ) + }) + + it('does not promote a ticket merely because the user selected it', () => { + const selected = ticket({ status: 'blocked' }) + expect(deriveWorkPriority(selected, null, null)).toBe('planning') + }) +}) diff --git a/web/src/lib/starmap/priority.ts b/web/src/lib/starmap/priority.ts new file mode 100644 index 0000000..0fed7f1 --- /dev/null +++ b/web/src/lib/starmap/priority.ts @@ -0,0 +1,30 @@ +import type { Ticket } from './model' +import type { SessionState } from './session' + +export type WorkPriority = + | 'attention' + | 'doing_now' + | 'my_next' + | 'my_future' + | 'available_next' + | 'team_work' + | 'planning' + | 'resolved' + | 'out_of_scope' + +export function deriveWorkPriority( + ticket: Ticket, + session: SessionState | null, + currentIssue: number | null, +): WorkPriority { + if (ticket.status === 'resolved') return 'resolved' + if (ticket.status === 'out_of_scope') return 'out_of_scope' + if (session === 'blocked' || session === 'dead') return 'attention' + if (session === 'implementing' || ticket.num === currentIssue) return 'doing_now' + if (ticket.assignedToViewer) { + return ticket.readyForAgent ? 'my_next' : 'my_future' + } + if (ticket.readyForAgent && ticket.status !== 'claimed') return 'available_next' + if (ticket.status === 'claimed') return 'team_work' + return 'planning' +} diff --git a/web/src/lib/starmap/session.ts b/web/src/lib/starmap/session.ts index e6a8b9c..a8d3b61 100644 --- a/web/src/lib/starmap/session.ts +++ b/web/src/lib/starmap/session.ts @@ -1,7 +1,7 @@ // The session overlay's vocabulary: what a live session looks like on top of a // star, and how that state is derived from the pushed model. // -// The base palette (theme.ts) is spent on the six derived statuses. chartr +// The base palette (theme.ts) is spent on the derived work priorities. chartr // adds a second axis — a session's liveness — so the overlay is deliberately // *not* more colour: the session is a **body**, an amber moon orbiting the star // it holds (spec, stories 25–27). Liveness is the moon's motion. diff --git a/web/src/lib/starmap/starmap.test.ts b/web/src/lib/starmap/starmap.test.ts index dd5dabc..8252c29 100644 --- a/web/src/lib/starmap/starmap.test.ts +++ b/web/src/lib/starmap/starmap.test.ts @@ -922,7 +922,7 @@ describe('label placement', () => { const readyWorld = sm.positions()[30] const readyRing = arcs.find((arc) => - Math.abs(arc.worldRadius - (8.1 * 1.25 + 8)) < 1e-6 && + Math.abs(arc.worldRadius - 18) < 1e-6 && Math.hypot(arc.worldX - readyWorld.x, arc.worldY - readyWorld.y) < 4, ) expect(readyRing).toBeDefined() diff --git a/web/src/lib/starmap/starmap.ts b/web/src/lib/starmap/starmap.ts index e6bea95..de39888 100644 --- a/web/src/lib/starmap/starmap.ts +++ b/web/src/lib/starmap/starmap.ts @@ -18,8 +18,9 @@ // 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 { STAR, LABEL, SESSION_HUE, hexA, type VisualState } from './theme' import { GRAMMAR, type SessionState } from './session' +import { deriveWorkPriority } from './priority' import { analyzeFocus, type Focus } from './focus' import { edgeKey, isMiniWorkflowEdge, workflowEdges, type WorkflowEdge } from './workflow' import { writeMiniEdgeCurve, type MutableMiniCurve } from './workflow-geometry' @@ -53,7 +54,6 @@ export type SelectHandler = (num: number | null) => void // StarMap.svelte through tokens.ts and handed in at the seam; the renderer // itself never reads CSS (ADR 0010). const DEFAULT_BG = '#05070d' -const ISSUE_RADIUS_SCALE = 1.25 const CONTEXT_ALPHA = 0.3 const CONTEXT_EDGE_ALPHA = 0.45 const SELECTED_EDGE_WIDTH_SCALE = 1.7 @@ -104,12 +104,15 @@ type Box = LabelBox // 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, - my_next: 1, - claimed: 2, - resolved: 3, - blocked: 4, - out_of_scope: 5, + attention: 0, + doing_now: 1, + my_next: 2, + my_future: 3, + available_next: 4, + team_work: 5, + planning: 6, + resolved: 7, + out_of_scope: 8, } // A star just off-screen can still own a label that reaches back on-screen, so @@ -358,8 +361,8 @@ export class StarMap { if (!n) continue n.title = t.title n.type = t.type - const vstate = visualState(t) const sstate = sessions[t.num] ?? null + const vstate = deriveWorkPriority(t, sstate, currentIssue) if (vstate !== n.vstate || sstate !== n.sstate) { n.flare = 1 changed.push(`#${t.num < 10 ? '0' : ''}${t.num} → ${sstate ?? vstate.replace('_', ' ')}`) @@ -398,7 +401,7 @@ export class StarMap { title: t.title, type: t.type, parentIssue: t.parentIssue, - vstate: visualState(t), + vstate: deriveWorkPriority(t, sessions[t.num] ?? null, currentIssue), sstate: sessions[t.num] ?? null, x: p.x, y: p.y, @@ -559,7 +562,7 @@ export class StarMap { } #radius(n: Node): number { - return STAR[n.vstate].r * ISSUE_RADIUS_SCALE + return STAR[n.vstate].r } // Hit-test a screen point and, if it lands on a star, select and emit it — the @@ -1091,11 +1094,8 @@ export class StarMap { 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) - 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) + const pulse = 1 + const gr = c.gr * (1 + fl * 0.5) const grd = g.createRadialGradient(x, y, 0, x, y, gr) grd.addColorStop(0, hexA(c.glow, Math.min(1, 0.85 * pulse + fl * 0.5))) @@ -1115,7 +1115,7 @@ export class StarMap { g.arc(x, y, cr + 4, 0, TAU) g.stroke() } - if (n.vstate === 'my_next') { + if (c.solid && n.vstate !== 'resolved') { g.fillStyle = c.core g.beginPath() g.arc(x, y, cr, 0, TAU) @@ -1158,21 +1158,6 @@ export class StarMap { 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) { - 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.stroke() - g.strokeStyle = hexA(c.core, 0.18 + 0.14 * beat) - g.lineWidth = 1 - g.beginPath() - g.arc(x, y, cr + 11 + 1.8 * beat, 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)' diff --git a/web/src/lib/starmap/theme.ts b/web/src/lib/starmap/theme.ts index e1197f8..4e99c4f 100644 --- a/web/src/lib/starmap/theme.ts +++ b/web/src/lib/starmap/theme.ts @@ -1,96 +1,46 @@ -// The star-map's visual vocabulary for the five base states, cribbed from the -// shipped wayfinder-maps viewer's palette so the feel carries over (ADR 0010: -// constants cribbed to prevent feel-drift). Status is the whole star — colour, -// size, glow, pulse (starmap-design.md, decision 4). Ticket type rides only in -// the label, never the celestial body. -// -// chartr derives its ticket status from `.plan/` (ADR 0004, amended): a -// ticket is open, claimed, resolved, or out_of_scope, with a `frontier` flag -// splitting open into the takeable edge and the still-blocked interior. That is -// exactly five visual states, and this module maps the derived status onto them. -// Session liveness (working / blocked / dead) is a strictly-additive overlay a -// later ticket layers on top — this palette is only the base star. +// The star-map palette expresses work priority, while session liveness remains +// an additive moon/mark overlay. Keeping the palette keyed by the derived +// priority prevents transport status from leaking into rendering decisions. -// Derived from chartr (https://github.com/rengwu/chartr), MIT, Copyright (c) 2026 John Goh. +import type { WorkPriority } from './priority' -import type { Ticket } from './model' - -export type VisualState = - | 'resolved' - | 'frontier' - | 'my_next' - | 'claimed' - | 'blocked' - | 'out_of_scope' +export type VisualState = WorkPriority export interface StarStyle { core: string glow: string r: number gr: number + solid: boolean } -// The five base states, ported verbatim from the viewer's theme — chartr -// adds none of its own (the `proposed` star went with the review feature). -// Ticket 04: the card the map sits on moved from a near-black `#05070d` to the -// theme's warm near-black `--card` (`oklch(0.228 0.013 107.4)`, ~`#1d1d16`) — -// meaningfully lighter than before. Four of five states still clear WCAG-ish -// contrast comfortably against it; `out_of_scope`, deliberately the dimmest -// star, fell under 4:1. Its three values are lifted just enough to stay -// legible on the new card; sizes, glow radii, and every other state are -// untouched (map decision: this is a palette re-tune, not a renderer change). export const STAR: Record = { - resolved: { core: '#b9d6c4', glow: '#5b9077', r: 5.4, gr: 24 }, - frontier: { core: '#8ad8ff', glow: '#2f9be0', r: 8.1, gr: 49 }, - my_next: { core: '#8ad8ff', glow: '#2f9be0', r: 8.8, gr: 49 }, - claimed: { core: '#ffd873', glow: '#ffb020', r: 7.2, gr: 36 }, - blocked: { core: '#e2c3c3', glow: '#9a6f6f', r: 4.5, gr: 20 }, - out_of_scope: { core: '#948da4', glow: '#6b6478', r: 4.5, gr: 18 }, + attention: { core: '#ffd873', glow: '#ffb020', r: 12, gr: 52, solid: true }, + doing_now: { core: '#ffd873', glow: '#ffb020', r: 12, gr: 49, solid: true }, + my_next: { core: '#8ad8ff', glow: '#2f9be0', r: 11, gr: 49, solid: true }, + my_future: { core: '#8ad8ff', glow: '#2f9be0', r: 10, gr: 42, solid: false }, + available_next: { core: '#8ed7ac', glow: '#3b9f68', r: 10, gr: 42, solid: false }, + team_work: { core: '#b9a7ee', glow: '#775dc1', r: 9, gr: 36, solid: true }, + planning: { core: '#aaa0bd', glow: '#716884', r: 5.625, gr: 20, solid: false }, + resolved: { core: '#b9d6c4', glow: '#5b9077', r: 6.75, gr: 24, solid: true }, + out_of_scope: { core: '#948da4', glow: '#6b6478', r: 5.625, gr: 18, solid: false }, } export const LABEL: Record = { - resolved: '#a2c1ac', - frontier: '#b3e5ff', + attention: '#ffe6a0', + doing_now: '#ffe6a0', my_next: '#b3e5ff', - claimed: '#ffe6a0', - blocked: '#d0b3b3', + my_future: '#b3e5ff', + available_next: '#b9eccd', + team_work: '#d6ccf6', + planning: '#c8bfd7', + resolved: '#a2c1ac', out_of_scope: '#a89fb2', } -// 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 -// exists is this at-a-glance read. -export function visualState( - t: Pick, -): VisualState { - if (t.assignedToViewer && t.readyForAgent) return 'my_next' - switch (t.status) { - case 'resolved': - return 'resolved' - case 'claimed': - return 'claimed' - case 'out_of_scope': - return 'out_of_scope' - case 'open': - default: - return t.frontier ? 'frontier' : 'blocked' - } -} - -// The session overlay's hues (ticket 13). They live here with the five base -// states because the star-map's palette is the island's own exempt data-viz -// colour (docs/design-system.md) — the chrome around it stays monochrome. The -// grammar these serve, and the non-colour channel each state also carries, is -// session.ts; the amber is the spec's session moon (story 25), so the set is -// closed here, not open to growth. export const SESSION_HUE = { - // The session itself: the same amber as a live claim, because the moon *is* - // the claim's body. session: '#ffd873', - // The island's own chrome: the ticker line naming what just changed. gold: '#ffe6a0', - // A dead session greys its whole orbital apparatus, not just the moon. dead: '#6b7280', } as const From 88f56eb6649e5c95a695350a5c9af24dfea68cb5 Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:44:31 -0500 Subject: [PATCH 05/17] feat: keep active work visible at every zoom (#94) --- web/src/lib/starmap/core-visual.test.ts | 69 +++++++++++++++++++++++-- web/src/lib/starmap/starmap.ts | 33 +++++++++--- web/src/lib/starmap/theme.ts | 19 +++---- 3 files changed, 100 insertions(+), 21 deletions(-) diff --git a/web/src/lib/starmap/core-visual.test.ts b/web/src/lib/starmap/core-visual.test.ts index 8db251f..93c4ad8 100644 --- a/web/src/lib/starmap/core-visual.test.ts +++ b/web/src/lib/starmap/core-visual.test.ts @@ -7,6 +7,7 @@ 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 ArcCenter = { x: number; y: number; radius: number } const RESOLVED: Ticket = { num: 1, @@ -95,9 +96,11 @@ function recordingContext(): { ctx: Record fills: Fill[] strokes: Stroke[] + arcs: ArcCenter[] } { const fills: Fill[] = [] const strokes: Stroke[] = [] + const arcs: ArcCenter[] = [] let arc: Arc | undefined const ctx: Record = { createRadialGradient: () => { @@ -110,8 +113,9 @@ function recordingContext(): { beginPath: () => { arc = undefined }, - arc: (_x: number, _y: number, radius: number) => { + arc: (x: number, y: number, radius: number) => { arc = { radius } + arcs.push({ x, y, radius }) }, fill: () => fills.push({ style: ctx.fillStyle as string | Gradient, arc }), stroke: () => @@ -139,7 +143,7 @@ function recordingContext(): { ]) { ctx[method] = () => {} } - return { ctx, fills, strokes } + return { ctx, fills, strokes, arcs } } describe('issue core visual grammar', () => { @@ -169,7 +173,14 @@ describe('issue core visual grammar', () => { ticket: Ticket, currentIssue: number | null = null, session: SessionState | null = null, - ): { fills: Fill[]; strokes: Stroke[] } { + options: { scale?: number; timeMs?: number; reducedMotion?: boolean } = {}, + ): { fills: Fill[]; strokes: Stroke[]; arcs: ArcCenter[] } { + now.mockReturnValue(options.timeMs ?? 0) + vi.stubGlobal('matchMedia', () => ({ + matches: options.reducedMotion ?? false, + addEventListener: () => {}, + removeEventListener: () => {}, + })) const recording = recordingContext() getContext.mockReturnValue(recording.ctx as never) const host = document.createElement('div') @@ -179,6 +190,9 @@ describe('issue core visual grammar', () => { const map = new StarMap() map.mount(host) map.setModel([ticket], session ? { [ticket.num]: session } : {}, currentIssue) + if (options.scale !== undefined) { + map.restoreCamera({ x: 500, y: 350, s: options.scale }) + } const frame = frames.pop() frames = [] frame?.(0) @@ -214,7 +228,7 @@ describe('issue core visual grammar', () => { }) it('paints doing now and team work as distinct solid cores', () => { - const doing = paint(BLOCKED, BLOCKED.num) + const doing = paint(BLOCKED, BLOCKED.num, null, { reducedMotion: true }) expect(doing.fills[1]).toEqual({ style: '#ffd873', arc: { radius: 12 } }) expect(doing.fills.some((fill) => fill.style === '#000')).toBe(false) @@ -230,6 +244,51 @@ describe('issue core visual grammar', () => { expect(attention.fills.some((fill) => fill.style === '#000')).toBe(false) }) + it('keeps active priorities readable with screen-space radius floors', () => { + for (const expected of [ + { ticket: BLOCKED, current: BLOCKED.num, radius: 50 }, + { ticket: MY_NEXT, current: null, radius: 42.5 }, + { ticket: MY_FUTURE, current: null, radius: 35 }, + { ticket: AVAILABLE_NEXT, current: null, radius: 35 }, + { ticket: CLAIMED, current: null, radius: 30 }, + { ticket: OUT_OF_SCOPE, current: null, radius: 5.625 }, + ]) { + const { fills } = paint(expected.ticket, expected.current, null, { + scale: 0.2, + reducedMotion: true, + }) + expect(fills[1].arc?.radius).toBe(expected.radius) + } + + const session = paint(CLAIMED, null, 'implementing', { + scale: 0.2, + reducedMotion: true, + }) + expect(session.arcs).toContainEqual(expect.objectContaining({ radius: 60 })) + }) + + it('pulses only doing-now cores from 1.00 to 1.08 and freezes under reduced motion', () => { + const lowMs = ((Math.PI * 1.5) / 2.8) * 1000 + const highMs = ((Math.PI * 0.5) / 2.8) * 1000 + + expect(paint(BLOCKED, BLOCKED.num, null, { timeMs: lowMs }).fills[1].arc?.radius).toBeCloseTo(12) + expect(paint(BLOCKED, BLOCKED.num, null, { timeMs: highMs }).fills[1].arc?.radius).toBeCloseTo(12.96) + expect(paint(MY_NEXT, null, null, { timeMs: highMs }).fills[1].arc?.radius).toBe(11) + expect( + paint(BLOCKED, BLOCKED.num, null, { timeMs: highMs, reducedMotion: true }).fills[1].arc?.radius, + ).toBe(12) + }) + + it('freezes the existing session proton under reduced motion', () => { + const moon = (timeMs: number, reducedMotion: boolean) => + paint(CLAIMED, null, 'implementing', { timeMs, reducedMotion }).arcs.find( + (arc) => arc.radius === 2.7, + ) + + expect(moon(0, false)).not.toEqual(moon(1000, false)) + expect(moon(0, true)).toEqual(moon(1000, true)) + }) + it('paints future, available, planning, and not-planned work as hollow priority cores', () => { for (const expected of [ { @@ -295,7 +354,7 @@ describe('issue core visual grammar', () => { }) it('keeps both CURRENT rings around the solid doing-now core', () => { - const { fills, strokes } = paint(BLOCKED, BLOCKED.num) + const { fills, strokes } = paint(BLOCKED, BLOCKED.num, null, { reducedMotion: true }) expect(fills).toHaveLength(2) expect(fills[1]).toEqual({ style: '#ffd873', arc: { radius: 12 } }) diff --git a/web/src/lib/starmap/starmap.ts b/web/src/lib/starmap/starmap.ts index de39888..8064ab5 100644 --- a/web/src/lib/starmap/starmap.ts +++ b/web/src/lib/starmap/starmap.ts @@ -274,6 +274,8 @@ export class StarMap { // actually leaves free, in either docking. #insets = { top: 16, right: 16, bottom: 16, left: 16 } #clock = 0 + #motionClock = 0 + #reducedMotion = false #last = 0 #raf = 0 // The live WebKit pinch, if one is in flight — it also mutes the wheel path, @@ -316,6 +318,18 @@ export class StarMap { this.#ctx = canvas.getContext('2d') this.#dpr = Math.max(1, (typeof window !== 'undefined' && window.devicePixelRatio) || 1) + const motionQuery = typeof window !== 'undefined' && window.matchMedia + ? window.matchMedia('(prefers-reduced-motion: reduce)') + : null + if (motionQuery) { + this.#reducedMotion = motionQuery.matches + const onMotionPreference = (event: MediaQueryListEvent) => { + this.#reducedMotion = event.matches + } + motionQuery.addEventListener?.('change', onMotionPreference) + this.#detach.push(() => motionQuery.removeEventListener?.('change', onMotionPreference)) + } + this.#measure() if (typeof ResizeObserver !== 'undefined') { this.#ro = new ResizeObserver(() => this.#onResize()) @@ -562,7 +576,8 @@ export class StarMap { } #radius(n: Node): number { - return STAR[n.vstate].r + const style = STAR[n.vstate] + return Math.max(style.r, style.minScreen / this.#cam.s) } // Hit-test a screen point and, if it lands on a star, select and emit it — the @@ -921,11 +936,12 @@ export class StarMap { if (dt < 0 || dt > 0.1) dt = 0.016 this.#last = t this.#clock = t + this.#motionClock = this.#reducedMotion ? 0 : t for (const n of this.#nodes) { const ph = n.num * 1.7 - n._x = n.x + Math.sin(this.#clock * 0.7 + ph) * 2.4 - n._y = n.y + Math.cos(this.#clock * 0.55 + ph) * 2.4 + n._x = this.#reducedMotion ? n.x : n.x + Math.sin(this.#motionClock * 0.7 + ph) * 2.4 + n._y = this.#reducedMotion ? n.y : n.y + Math.cos(this.#motionClock * 0.55 + ph) * 2.4 if (n.flare > 0) n.flare = Math.max(0, n.flare - dt / 1.1) } this.#easeCamera(dt) @@ -968,7 +984,7 @@ export class StarMap { for (const n of this.#nodes) { g.save() if (focused && !this.#focus.emphasized.has(n.num)) g.globalAlpha = CONTEXT_ALPHA - this.#drawStar(g, n, this.#clock) + this.#drawStar(g, n, this.#motionClock) g.restore() } g.restore() @@ -1094,8 +1110,11 @@ export class StarMap { const x = n._x, y = n._y, fl = n.flare || 0 + const cr = this.#radius(n) + const beat = 0.5 + 0.5 * Math.sin(t * 2.8) + const corePulse = n.vstate === 'doing_now' && !this.#reducedMotion ? 1 + 0.08 * beat : 1 const pulse = 1 - const gr = c.gr * (1 + fl * 0.5) + const gr = c.gr * (cr / c.r) * (1 + fl * 0.5) const grd = g.createRadialGradient(x, y, 0, x, y, gr) grd.addColorStop(0, hexA(c.glow, Math.min(1, 0.85 * pulse + fl * 0.5))) @@ -1106,7 +1125,7 @@ export class StarMap { g.arc(x, y, gr, 0, TAU) g.fill() - const cr = this.#radius(n) + const paintedCoreRadius = cr * corePulse const hasSubissueRim = n.parentIssue !== null && n.vstate !== 'resolved' && n.vstate !== 'out_of_scope' if (hasSubissueRim) { g.strokeStyle = SUBISSUE_RIM @@ -1118,7 +1137,7 @@ export class StarMap { if (c.solid && n.vstate !== 'resolved') { g.fillStyle = c.core g.beginPath() - g.arc(x, y, cr, 0, TAU) + g.arc(x, y, paintedCoreRadius, 0, TAU) g.fill() } else if (n.vstate === 'resolved') { const cg = g.createRadialGradient(x, y, 0, x, y, cr * 1.35) diff --git a/web/src/lib/starmap/theme.ts b/web/src/lib/starmap/theme.ts index 4e99c4f..753e3ec 100644 --- a/web/src/lib/starmap/theme.ts +++ b/web/src/lib/starmap/theme.ts @@ -10,20 +10,21 @@ export interface StarStyle { core: string glow: string r: number + minScreen: number gr: number solid: boolean } export const STAR: Record = { - attention: { core: '#ffd873', glow: '#ffb020', r: 12, gr: 52, solid: true }, - doing_now: { core: '#ffd873', glow: '#ffb020', r: 12, gr: 49, solid: true }, - my_next: { core: '#8ad8ff', glow: '#2f9be0', r: 11, gr: 49, solid: true }, - my_future: { core: '#8ad8ff', glow: '#2f9be0', r: 10, gr: 42, solid: false }, - available_next: { core: '#8ed7ac', glow: '#3b9f68', r: 10, gr: 42, solid: false }, - team_work: { core: '#b9a7ee', glow: '#775dc1', r: 9, gr: 36, solid: true }, - planning: { core: '#aaa0bd', glow: '#716884', r: 5.625, gr: 20, solid: false }, - resolved: { core: '#b9d6c4', glow: '#5b9077', r: 6.75, gr: 24, solid: true }, - out_of_scope: { core: '#948da4', glow: '#6b6478', r: 5.625, gr: 18, solid: false }, + attention: { core: '#ffd873', glow: '#ffb020', r: 12, minScreen: 10, gr: 52, solid: true }, + doing_now: { core: '#ffd873', glow: '#ffb020', r: 12, minScreen: 10, gr: 49, solid: true }, + my_next: { core: '#8ad8ff', glow: '#2f9be0', r: 11, minScreen: 8.5, gr: 49, solid: true }, + my_future: { core: '#8ad8ff', glow: '#2f9be0', r: 10, minScreen: 7, gr: 42, solid: false }, + available_next: { core: '#8ed7ac', glow: '#3b9f68', r: 10, minScreen: 7, gr: 42, solid: false }, + team_work: { core: '#b9a7ee', glow: '#775dc1', r: 9, minScreen: 6, gr: 36, solid: true }, + planning: { core: '#aaa0bd', glow: '#716884', r: 5.625, minScreen: 0, gr: 20, solid: false }, + resolved: { core: '#b9d6c4', glow: '#5b9077', r: 6.75, minScreen: 0, gr: 24, solid: true }, + out_of_scope: { core: '#948da4', glow: '#6b6478', r: 5.625, minScreen: 0, gr: 18, solid: false }, } export const LABEL: Record = { From e8a05a196fff75f98ff02d280a4c675921c55e03 Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:50:43 -0500 Subject: [PATCH 06/17] feat: quiet traversed connections around active work (#95) --- web/src/lib/starmap/edge-visual.test.ts | 142 +++++++++++++++----- web/src/lib/starmap/starmap.ts | 33 ++--- web/src/lib/starmap/workflow-visual.test.ts | 10 +- web/src/lib/starmap/workflow-visual.ts | 7 +- 4 files changed, 138 insertions(+), 54 deletions(-) diff --git a/web/src/lib/starmap/edge-visual.test.ts b/web/src/lib/starmap/edge-visual.test.ts index 1133270..17db9cd 100644 --- a/web/src/lib/starmap/edge-visual.test.ts +++ b/web/src/lib/starmap/edge-visual.test.ts @@ -80,7 +80,7 @@ const SELECTION_FIXTURE: Ticket[] = [ function edgeStrokes(render: { strokes: Stroke[] }): Stroke[] { return render.strokes.filter((stroke) => - ['rgba(190,225,200,0.82)', 'rgba(174,192,218,0.62)', 'rgba(170,145,255,0.78)'].includes(stroke.color), + ['rgba(150,178,160,0.36)', 'rgba(174,192,218,0.62)', 'rgba(170,145,255,0.78)'].includes(stroke.color), ) } @@ -174,23 +174,21 @@ describe('dependency-edge visual treatment', () => { } function expectParticleMotion(render: { fills: Fill[] }, edgeAlpha: number, edgePhase = 0.27): void { - const halos = render.fills.filter((fill) => typeof fill.color === 'string' && fill.color.startsWith('rgba(190,225,200,') && fill.arcs[0]?.radius === 5) - const cores = render.fills.filter((fill) => typeof fill.color === 'string' && fill.color.startsWith('rgba(220,255,230,') && fill.arcs[0]?.radius === 2.6) - expect(halos).toHaveLength(3) - expect(cores).toHaveLength(3) - for (let index = 0; index < 3; index++) { - const u = (0.1 + index / 3 + edgePhase) % 1 - const expectedHalo = 0.14 + 0.18 * Math.sin(Math.PI * u) - const expectedCore = 0.45 + 0.5 * Math.sin(Math.PI * u) - const halo = halos[index] - const core = cores[index] - const haloAlpha = Number((halo.color as string).slice((halo.color as string).lastIndexOf(',') + 1, -1)) - const coreAlpha = Number((core.color as string).slice((core.color as string).lastIndexOf(',') + 1, -1)) - expect(halo.alpha).toBe(edgeAlpha) - expect(core.alpha).toBe(edgeAlpha) - expect(haloAlpha * halo.alpha).toBeCloseTo(expectedHalo * edgeAlpha) - expect(coreAlpha * core.alpha).toBeCloseTo(expectedCore * edgeAlpha) - expect(core.arcs[0]).toMatchObject({ x: halo.arcs[0].x, y: halo.arcs[0].y }) + const particles = render.fills.filter((fill) => + typeof fill.color === 'string' && + fill.color.startsWith('rgba(190,218,198,') && + fill.arcs[0]?.radius === 1.8, + ) + expect(particles).toHaveLength(2) + for (let index = 0; index < 2; index++) { + const u = (0.1 + index / 2 + edgePhase) % 1 + const expectedAlpha = 0.35 + 0.4 * Math.sin(Math.PI * u) + const particle = particles[index] + const particleAlpha = Number( + (particle.color as string).slice((particle.color as string).lastIndexOf(',') + 1, -1), + ) + expect(particle.alpha).toBe(edgeAlpha) + expect(particleAlpha * particle.alpha).toBeCloseTo(expectedAlpha * edgeAlpha) } } @@ -208,12 +206,12 @@ describe('dependency-edge visual treatment', () => { const focused = paint(EDGE_FIXTURE, 2) const { strokes, fills } = focused - const resolved = strokes.find((stroke) => stroke.color === 'rgba(190,225,200,0.82)')! + const resolved = strokes.find((stroke) => stroke.color === 'rgba(150,178,160,0.36)')! const unresolved = strokes.find((stroke) => stroke.color === 'rgba(174,192,218,0.62)')! - expect(resolved).toMatchObject({ width: 3, dash: [], cap: 'round', alpha: 1 }) + expect(resolved).toMatchObject({ width: 1.6, dash: [], cap: 'round', alpha: 1 }) expect(unresolved).toMatchObject({ width: 2.4, dash: [7, 7], cap: 'round', alpha: 0.45 }) - const resolvedArrow = fills.find((fill) => fill.color === '#d9f3df')! + const resolvedArrow = fills.find((fill) => fill.color === 'rgba(190,218,198,0.52)')! const unresolvedArrow = fills.find((fill) => fill.color === '#c8d5e8')! expect(resolvedArrow.alpha).toBe(1) expect(unresolvedArrow.alpha).toBe(0.45) @@ -232,7 +230,7 @@ describe('dependency-edge visual treatment', () => { // Current issue 4 has no ready-to-current path: both edges are context. // This makes the resolved 1 → 2 particle flow itself prove the multiplier. - expectParticleMotion(paint(EDGE_FIXTURE, 4), 0.45) + expect(paint(EDGE_FIXTURE, 4).fills.filter((fill) => fill.arcs[0]?.radius === 1.8)).toEqual([]) }) it('scopes selection emphasis to direct dependency edges and restores ordinary treatment', () => { @@ -246,12 +244,12 @@ describe('dependency-edge visual treatment', () => { expect(selectedEdges.map(({ color, width, alpha }) => ({ color, width, alpha }))).toEqual([ { color: 'rgba(174,192,218,0.62)', width: 2.4, alpha: 0.45 }, { color: 'rgba(174,192,218,0.62)', width: 2.4, alpha: 0.45 }, - { color: 'rgba(190,225,200,0.82)', width: 5.1, alpha: 1 }, + { color: 'rgba(150,178,160,0.36)', width: 2.72, alpha: 1 }, { color: 'rgba(174,192,218,0.62)', width: 4.08, alpha: 1 }, ]) expect( - selectedEdges.find((stroke) => stroke.color === 'rgba(190,225,200,0.82)'), - ).toMatchObject({ width: 5.1, dash: [], alpha: 1 }) + selectedEdges.find((stroke) => stroke.color === 'rgba(150,178,160,0.36)'), + ).toMatchObject({ width: 2.72, dash: [], alpha: 1 }) const unresolvedEdges = selectedEdges.filter( (stroke) => stroke.color === 'rgba(174,192,218,0.62)', ) @@ -264,7 +262,7 @@ describe('dependency-edge visual treatment', () => { alpha: 0.45, }) - const resolvedArrow = selected.fills.find((fill) => fill.color === '#d9f3df')! + const resolvedArrow = selected.fills.find((fill) => fill.color === 'rgba(190,218,198,0.52)')! const unresolvedArrows = selected.fills.filter((fill) => fill.color === '#c8d5e8') expect(resolvedArrow.alpha).toBe(1) expectArrowDimensions(resolvedArrow, 15, 8.125) @@ -272,11 +270,11 @@ describe('dependency-edge visual treatment', () => { const contextUnresolvedArrow = unresolvedArrows.find((fill) => fill.alpha === 0.45)! expectArrowDimensions(selectedUnresolvedArrow, 15, 8.125) expectArrowDimensions(contextUnresolvedArrow, 12, 6.5) - expectParticleMotion(selected, 1) + expect(selected.fills.filter((fill) => fill.arcs[0]?.radius === 1.8)).toEqual([]) const deselected = edgeStrokes(deselectedRender) expect(deselected.map(({ width, alpha }) => ({ width, alpha }))).toEqual([ - { width: 3, alpha: 0.45 }, + { width: 1.6, alpha: 0.45 }, { width: 2.4, alpha: 0.45 }, { width: 2.4, alpha: 0.45 }, { width: 2.4, alpha: 0.45 }, @@ -297,31 +295,103 @@ describe('dependency-edge visual treatment', () => { ]) expect(edgeStrokes(restored).map(({ width, alpha }) => ({ width, alpha }))).toEqual([ - { width: 3, alpha: 0.45 }, + { width: 1.6, alpha: 0.45 }, { width: 2.4, alpha: 0.45 }, { width: 2.4, alpha: 0.45 }, { width: 2.4, alpha: 0.45 }, ]) }) - it('animates exactly three halo/core pairs on a traversed sequence edge at full path alpha', () => { + it('animates exactly two subtle particles on a traversed edge into available work', () => { installFrameHarness() expectParticleMotion(paint(sequenceFixture('resolved', 'frontier'), 16), 1, 0.47) }) + it('renders traversed history as a subtle static line unless it points into active work', () => { + installFrameHarness() + + const render = paint(sequenceFixture('resolved', 'blocked')) + const history = render.strokes.find( + (stroke) => stroke.color === 'rgba(150,178,160,0.36)', + )! + const arrow = render.fills.find( + (fill) => fill.color === 'rgba(190,218,198,0.52)', + )! + + expect(history).toMatchObject({ width: 1.6, dash: [], cap: 'round', alpha: 1 }) + expect(arrow).toBeDefined() + expect(render.fills.filter((fill) => fill.arcs[0]?.radius === 1.8)).toHaveLength(0) + + for (const destination of [ + { status: 'claimed' as const, assignedToViewer: true }, + { status: 'claimed' as const, assignedToViewer: false }, + ]) { + const tickets = sequenceFixture('resolved', destination.status).map((ticket) => + ticket.num === 38 ? { ...ticket, ...destination } : ticket, + ) + expect(paint(tickets).fills.filter((fill) => fill.arcs[0]?.radius === 1.8)).toEqual([]) + } + }) + + it('animates exactly two small particles only into doing, my-next, and available work', () => { + installFrameHarness() + + const variants: Array<{ tickets: Ticket[]; current: number | null }> = [ + { tickets: sequenceFixture('resolved', 'blocked'), current: 38 }, + { + tickets: sequenceFixture('resolved', 'claimed').map((ticket) => + ticket.num === 38 + ? { ...ticket, readyForAgent: true, assignedToViewer: true } + : ticket, + ), + current: null, + }, + { tickets: sequenceFixture('resolved', 'frontier'), current: null }, + ] + + for (const variant of variants) { + const particles = paint(variant.tickets, variant.current).fills.filter( + (fill) => fill.arcs[0]?.radius === 1.8, + ) + expect(particles).toHaveLength(2) + for (const particle of particles) { + expect(particle.color).toMatch(/^rgba\(190,218,198,0\.[0-9]+\)$/) + } + } + }) + + it('keeps motion directional, direct, and independent from selection', () => { + installFrameHarness() + const chain: Ticket[] = [ + { num: 1, slug: '1', title: 'history', type: 'task', status: 'resolved', blockedBy: [], parentIssue: null, frontier: false }, + { num: 2, slug: '2', title: 'available', type: 'task', status: 'frontier', blockedBy: [1], parentIssue: null, frontier: true, readyForAgent: true }, + { num: 3, slug: '3', title: 'later', type: 'task', status: 'blocked', blockedBy: [2], parentIssue: null, frontier: false }, + ] + + expect(paint(chain).fills.filter((fill) => fill.arcs[0]?.radius === 1.8)).toHaveLength(2) + + const selectedStatic = paint(sequenceFixture('resolved', 'blocked'), null, [38]) + expect(selectedStatic.fills.filter((fill) => fill.arcs[0]?.radius === 1.8)).toHaveLength(0) + expect( + selectedStatic.strokes.find( + (stroke) => stroke.color === 'rgba(150,178,160,0.36)' && stroke.width > 1.6, + ), + ).toMatchObject({ width: 2.72, alpha: 1 }) + }) + it('does not animate a resolved sequence source whose destination remains blocked', () => { installFrameHarness() const render = paint(sequenceFixture('resolved', 'blocked'), 16) - expect(render.fills.filter((fill) => [5, 2.6].includes(fill.arcs[0]?.radius ?? 0))).toEqual([]) + expect(render.fills.filter((fill) => fill.arcs[0]?.radius === 1.8)).toEqual([]) }) it('does not animate an open sequence source whose destination is frontier', () => { installFrameHarness() const render = paint(sequenceFixture('open', 'frontier'), 16) - expect(render.fills.filter((fill) => [5, 2.6].includes(fill.arcs[0]?.radius ?? 0))).toEqual([]) + expect(render.fills.filter((fill) => fill.arcs[0]?.radius === 1.8)).toEqual([]) }) it('multiplies contextual traversed sequence particles by context edge alpha', () => { @@ -414,14 +484,14 @@ describe('dependency-edge visual treatment', () => { } const completed = paintChild('resolved') - const mintStrokes = completed.strokes.filter((stroke) => stroke.color === 'rgba(190,225,200,0.82)') - const mintArrows = completed.fills.filter((fill) => fill.color === '#d9f3df') + const mintStrokes = completed.strokes.filter((stroke) => stroke.color === 'rgba(150,178,160,0.36)') + const mintArrows = completed.fills.filter((fill) => fill.color === 'rgba(190,218,198,0.52)') expect(mintStrokes).toHaveLength(2) expect(mintArrows).toHaveLength(2) for (const stroke of mintStrokes) { - expect(stroke).toMatchObject({ width: 3, dash: [], cap: 'round', alpha: 1 }) + expect(stroke).toMatchObject({ width: 1.6, dash: [], cap: 'round', alpha: 1 }) expect(stroke.curves).toHaveLength(1) } - expect(completed.fills.filter((fill) => [5, 2.6].includes(fill.arcs[0]?.radius ?? 0))).toEqual([]) + expect(completed.fills.filter((fill) => fill.arcs[0]?.radius === 1.8)).toEqual([]) }) }) diff --git a/web/src/lib/starmap/starmap.ts b/web/src/lib/starmap/starmap.ts index 8064ab5..904f50d 100644 --- a/web/src/lib/starmap/starmap.ts +++ b/web/src/lib/starmap/starmap.ts @@ -1046,11 +1046,11 @@ export class StarMap { g.moveTo(ax, ay) g.quadraticCurveTo(cx, cy, bx, by) g.lineCap = 'round' - const usesResolvedStyle = mini ? e.state !== 'incomplete' : e.satisfied + const usesResolvedStyle = e.state !== 'incomplete' || e.satisfied const strokeScale = selected ? SELECTED_EDGE_WIDTH_SCALE : 1 if (usesResolvedStyle) { - g.strokeStyle = 'rgba(190,225,200,0.82)' - g.lineWidth = 3 * strokeScale + g.strokeStyle = 'rgba(150,178,160,0.36)' + g.lineWidth = 1.6 * strokeScale g.setLineDash([]) } else if (mini) { g.strokeStyle = 'rgba(170,145,255,0.78)' @@ -1063,21 +1063,20 @@ export class StarMap { } g.stroke() g.setLineDash([]) - // A satisfied edge (blocker resolved) flows particles blocker→dependent, so - // the frontier visibly ignites as paths clear (starmap-design.md dec. 5). - if (mini ? e.state === 'traversed' : e.satisfied) { - for (let k = 0; k < 3; k++) { - const u = mod(this.#clock * 0.1 + k / 3 + (e.from * 0.13 + e.to * 0.07), 1), + // Historical paths stay quiet. Only a traversed edge directed into work the + // operator can act on now carries two small particles toward that endpoint. + const animatesIntoActiveWork = + e.state === 'traversed' && + (b.vstate === 'doing_now' || b.vstate === 'my_next' || b.vstate === 'available_next') + if (animatesIntoActiveWork) { + for (let k = 0; k < 2; k++) { + const u = mod(this.#motionClock * 0.1 + k / 2 + (e.from * 0.13 + e.to * 0.07), 1), m = 1 - u const fx = m * m * ax + 2 * m * u * cx + u * u * bx, fy = m * m * ay + 2 * m * u * cy + u * u * by - g.fillStyle = 'rgba(190,225,200,' + (0.14 + 0.18 * Math.sin(u * Math.PI)) + ')' + g.fillStyle = 'rgba(190,218,198,' + (0.35 + 0.4 * Math.sin(u * Math.PI)) + ')' g.beginPath() - g.arc(fx, fy, 5, 0, TAU) - g.fill() - g.fillStyle = 'rgba(220,255,230,' + (0.45 + 0.5 * Math.sin(u * Math.PI)) + ')' - g.beginPath() - g.arc(fx, fy, 2.6, 0, TAU) + g.arc(fx, fy, 1.8, 0, TAU) g.fill() } } @@ -1101,7 +1100,11 @@ export class StarMap { g.lineTo(tipx - ux * ah + px * aw, tipy - uy * ah + py * aw) g.lineTo(tipx - ux * ah - px * aw, tipy - uy * ah - py * aw) g.closePath() - g.fillStyle = usesResolvedStyle ? '#d9f3df' : mini ? '#c7b8ff' : '#c8d5e8' + g.fillStyle = usesResolvedStyle + ? 'rgba(190,218,198,0.52)' + : mini + ? '#c7b8ff' + : '#c8d5e8' g.fill() } diff --git a/web/src/lib/starmap/workflow-visual.test.ts b/web/src/lib/starmap/workflow-visual.test.ts index 7fbf92b..6269df6 100644 --- a/web/src/lib/starmap/workflow-visual.test.ts +++ b/web/src/lib/starmap/workflow-visual.test.ts @@ -41,9 +41,15 @@ describe('workflow visual policy', () => { } expect(workflowVisualState(entryToOpenChild, tickets)).toBe('incomplete') - expect(workflowVisualState(returnFromResolvedChild, tickets)).toBe('completed') + expect(workflowVisualState(returnFromResolvedChild, tickets)).toBe('traversed') expect(workflowVisualState(sequenceFromResolvedToFrontier, tickets)).toBe('traversed') - expect(workflowVisualState(sequenceFromResolvedToBlocked, tickets)).toBe('incomplete') + expect(workflowVisualState(sequenceFromResolvedToBlocked, tickets)).toBe('traversed') + expect( + workflowVisualState( + { from: 3, to: 1, roles: ['dependency'], child: null }, + tickets, + ), + ).toBe('traversed') }) it('bows reverse directions apart and gives single edges a stable default side', () => { diff --git a/web/src/lib/starmap/workflow-visual.ts b/web/src/lib/starmap/workflow-visual.ts index 5a5d146..2fa0724 100644 --- a/web/src/lib/starmap/workflow-visual.ts +++ b/web/src/lib/starmap/workflow-visual.ts @@ -7,7 +7,12 @@ export function workflowVisualState( edge: WorkflowEdge, tickets: Map, ): WorkflowVisualState { - if (tickets.get(edge.from)?.status === 'resolved' && tickets.get(edge.to)?.status === 'frontier') { + const destination = tickets.get(edge.to) + const destinationIsOpen = + destination !== undefined && + destination.status !== 'resolved' && + destination.status !== 'out_of_scope' + if (tickets.get(edge.from)?.status === 'resolved' && destinationIsOpen) { return 'traversed' } if (edge.child !== null && tickets.get(edge.child)?.status === 'resolved') return 'completed' From 260dade6ca16ef799e8e1decffd4e7e1ea3ad865 Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:09:48 -0500 Subject: [PATCH 07/17] test: complete work-priority integration gates (#96) --- CHANGELOG.md | 5 +++++ crates/app/tests/application_process_test.rs | 1 + 2 files changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a6b580..1a4ad01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- Reworked the star map around viewer-aware work priorities: current and owned + work now remains prominent when zoomed out, team and closure states stay + distinct, account changes cannot reuse stale ownership, and completed paths + remain quiet except for subtle directional motion into immediately actionable + nodes. - 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 diff --git a/crates/app/tests/application_process_test.rs b/crates/app/tests/application_process_test.rs index 35691cf..aa8cd5d 100644 --- a/crates/app/tests/application_process_test.rs +++ b/crates/app/tests/application_process_test.rs @@ -33,6 +33,7 @@ async fn controlled_github() -> (String, tokio::task::JoinHandle<()>) { post(|| async { Json(json!({ "data": { + "viewer": { "login": "octocat" }, "repository": { "issues": { "pageInfo": { "hasNextPage": false, "endCursor": null }, From a8951d7d8ac6ac8f17c90d18ed1f9ef0b3bd2f41 Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:26:13 -0500 Subject: [PATCH 08/17] fix: address work-priority review findings --- crates/app/src/runtime.rs | 5 +- crates/app/tests/provider_activation_test.rs | 87 ++++++++++++++++++-- crates/core/src/provider.rs | 2 + crates/github/src/sync.rs | 4 + crates/github/tests/sync_test.rs | 14 ++++ web/src/lib/starmap/edge-visual.test.ts | 25 +++++- web/src/lib/starmap/priority.test.ts | 15 ++++ web/src/lib/starmap/priority.ts | 4 +- web/src/lib/starmap/starmap.test.ts | 21 +++++ 9 files changed, 167 insertions(+), 10 deletions(-) diff --git a/crates/app/src/runtime.rs b/crates/app/src/runtime.rs index 337de7d..b06912d 100644 --- a/crates/app/src/runtime.rs +++ b/crates/app/src/runtime.rs @@ -55,7 +55,10 @@ impl Provider for ProviderSlot { (current.clone(), self.generation.load(Ordering::Acquire)) }; let result = provider.fetch(repo).await; - if result.is_ok() && self.generation.load(Ordering::Acquire) == generation { + if self.generation.load(Ordering::Acquire) != generation { + return Err(ProviderError::Superseded); + } + if result.is_ok() { self.confirmed_generation .store(generation, Ordering::Release); } diff --git a/crates/app/tests/provider_activation_test.rs b/crates/app/tests/provider_activation_test.rs index b28cc21..3001767 100644 --- a/crates/app/tests/provider_activation_test.rs +++ b/crates/app/tests/provider_activation_test.rs @@ -1,7 +1,9 @@ -use std::sync::Arc; +use std::{sync::Arc, time::Duration}; -use stellr_app::runtime::ProviderSlot; -use stellr_core::{Provider, ProviderError, ProviderSnapshot, RepoRef}; +use stellr_app::runtime::{ProviderSlot, RuntimeOptions, SessionAuth, start}; +use stellr_core::{IssueState, Provider, ProviderError, ProviderSnapshot, RawIssue, RepoRef}; +use stellr_github::cache::Cache; +use stellr_server::spaces::{SpaceEntry, SpaceStore}; struct SignedOut; @@ -51,7 +53,18 @@ impl Provider for DelayedSuccess { self.release.notified().await; Ok(ProviderSnapshot { viewer_login: Some("previous-account".into()), - issues: vec![], + issues: vec![RawIssue { + number: 92, + parent_issue: None, + title: "Previous account work".into(), + body: String::new(), + state: IssueState::Open, + assignees: vec!["previous-account".into()], + milestone: None, + labels: vec!["ready-for-agent".into()], + blocked_by: vec![], + url: "https://github.com/teloverge/stellr/issues/92".into(), + }], }) } } @@ -96,7 +109,71 @@ async fn an_old_in_flight_success_cannot_confirm_a_replacement_generation() { slot.replace(Arc::new(SignedOut)).await; release.notify_one(); - fetch.await.unwrap().unwrap(); + assert!(matches!( + fetch.await.unwrap(), + Err(ProviderError::Superseded) + )); assert!(!slot.allows_cached_viewer_identity()); } + +#[tokio::test] +async fn a_superseded_fetch_never_reaches_the_live_model_or_cache() { + let profile = tempfile::tempdir().unwrap(); + let repo = RepoRef { + owner: "teloverge".into(), + name: "stellr".into(), + }; + let spaces_file = profile.path().join("spaces.toml"); + let cache_root = profile.path().join("cache"); + let mut spaces = SpaceStore::load(spaces_file.clone()); + spaces.add(SpaceEntry::new(repo.clone(), None)).unwrap(); + spaces.save().unwrap(); + + let started = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let slot = Arc::new(ProviderSlot::new(Arc::new(DelayedSuccess { + started: started.clone(), + release: release.clone(), + }))); + let started_wait = started.notified(); + let runtime = start( + RuntimeOptions { + address: "127.0.0.1:0".into(), + session_auth: SessionAuth::Disabled, + issue: None, + spaces_file, + cache_root: cache_root.clone(), + poll_interval: Duration::from_secs(60), + }, + slot.clone(), + ) + .await + .unwrap(); + let state = runtime.state(); + let mut models = state.hub.subscribe(); + started_wait.await; + + slot.replace(Arc::new(SignedOut)).await; + release.notify_one(); + + tokio::time::timeout(Duration::from_secs(2), models.changed()) + .await + .expect("superseded fetch should publish safe fallback state") + .unwrap(); + let model = models.borrow_and_update().clone(); + assert_eq!(model.spaces.len(), 1); + assert_eq!(model.spaces[0].viewer_login, None); + assert!(model.spaces[0].stars.is_empty()); + assert!(model.spaces[0].stale); + assert!( + model.spaces[0] + .error + .as_deref() + .is_some_and(|error| error.contains("provider changed")) + ); + assert!(Cache::new(cache_root).load(&repo).is_none()); + + runtime.shutdown_handle().shutdown(); + runtime.wait().await.unwrap(); +} diff --git a/crates/core/src/provider.rs b/crates/core/src/provider.rs index 9758a12..95ed49f 100644 --- a/crates/core/src/provider.rs +++ b/crates/core/src/provider.rs @@ -46,4 +46,6 @@ pub enum ProviderError { Http(String), #[error("response parsing failed: {0}")] Parse(String), + #[error("provider changed while the request was in flight")] + Superseded, } diff --git a/crates/github/src/sync.rs b/crates/github/src/sync.rs index d3fc03d..2dd10ef 100644 --- a/crates/github/src/sync.rs +++ b/crates/github/src/sync.rs @@ -164,6 +164,10 @@ impl Provider for GithubProvider { .map_err(|error| ProviderError::Parse(error.to_string())) })?; + if data.viewer.login.trim().is_empty() { + return Err(ProviderError::Parse("viewer login is empty".into())); + } + match viewer_login.as_deref() { Some(login) if login != data.viewer.login => { return Err(ProviderError::Parse( diff --git a/crates/github/tests/sync_test.rs b/crates/github/tests/sync_test.rs index d79a100..e88d604 100644 --- a/crates/github/tests/sync_test.rs +++ b/crates/github/tests/sync_test.rs @@ -122,6 +122,20 @@ async fn fetch_rejects_missing_viewer_identity_as_a_parse_failure() { assert!(error.to_string().contains("viewer")); } +#[tokio::test] +async fn fetch_rejects_an_empty_viewer_login_as_a_parse_failure() { + for login in ["", " "] { + let server = MockServer::start().await; + mount_graphql_response(&server, page_for_viewer(login, json!([]), false, None)).await; + + let provider = GithubProvider::with_base_uri("tok".into(), &server.uri()).unwrap(); + let error = provider.fetch(&repo()).await.unwrap_err(); + + assert!(matches!(error, ProviderError::Parse(_))); + assert!(error.to_string().contains("viewer login")); + } +} + #[tokio::test] async fn fetch_rejects_a_viewer_change_during_pagination() { let server = MockServer::start().await; diff --git a/web/src/lib/starmap/edge-visual.test.ts b/web/src/lib/starmap/edge-visual.test.ts index 17db9cd..7e91793 100644 --- a/web/src/lib/starmap/edge-visual.test.ts +++ b/web/src/lib/starmap/edge-visual.test.ts @@ -105,14 +105,14 @@ describe('dependency-edge visual treatment', () => { const realCancelRaf = globalThis.cancelAnimationFrame let frames: FrameRequestCallback[] = [] - function installFrameHarness(): void { + function installFrameHarness(): ReturnType { frames = [] globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => { frames.push(cb) return frames.length }) as never globalThis.cancelAnimationFrame = (() => {}) as never - vi.spyOn(performance, 'now').mockReturnValue(1_000) + return vi.spyOn(performance, 'now').mockReturnValue(1_000) } function paintFrames( @@ -197,6 +197,7 @@ describe('dependency-edge visual treatment', () => { globalThis.requestAnimationFrame = realRaf globalThis.cancelAnimationFrame = realCancelRaf vi.restoreAllMocks() + vi.unstubAllGlobals() document.body.replaceChildren() }) @@ -380,6 +381,26 @@ describe('dependency-edge visual treatment', () => { ).toMatchObject({ width: 2.72, alpha: 1 }) }) + it('freezes eligible edge particles when reduced motion is requested', () => { + const clock = installFrameHarness() + vi.stubGlobal('matchMedia', () => ({ + matches: true, + addEventListener: () => {}, + removeEventListener: () => {}, + })) + const [first, second] = paintFrames(sequenceFixture('resolved', 'frontier'), null, [ + () => clock.mockReturnValue(1_000), + () => clock.mockReturnValue(2_000), + ]) + const particlePositions = (render: { fills: Fill[] }) => + render.fills + .filter((fill) => fill.arcs[0]?.radius === 1.8) + .map((fill) => ({ x: fill.arcs[0].x, y: fill.arcs[0].y })) + + expect(particlePositions(first)).toHaveLength(2) + expect(particlePositions(second)).toEqual(particlePositions(first)) + }) + it('does not animate a resolved sequence source whose destination remains blocked', () => { installFrameHarness() diff --git a/web/src/lib/starmap/priority.test.ts b/web/src/lib/starmap/priority.test.ts index 4ff73d8..3b2b4fa 100644 --- a/web/src/lib/starmap/priority.test.ts +++ b/web/src/lib/starmap/priority.test.ts @@ -28,6 +28,21 @@ describe('work priority hierarchy', () => { it('treats current or actively implementing work as doing now', () => { expect(deriveWorkPriority(ticket({ num: 7 }), null, 7)).toBe('doing_now') expect(deriveWorkPriority(ticket(), 'implementing', null)).toBe('doing_now') + expect( + deriveWorkPriority(ticket({ num: 7, status: 'resolved' }), 'implementing', null), + ).toBe('doing_now') + expect( + deriveWorkPriority(ticket({ num: 7, status: 'out_of_scope' }), null, 7), + ).toBe('doing_now') + }) + + it('keeps session trouble above closed context', () => { + expect(deriveWorkPriority(ticket({ status: 'resolved' }), 'blocked', null)).toBe( + 'attention', + ) + expect(deriveWorkPriority(ticket({ status: 'out_of_scope' }), 'dead', null)).toBe( + 'attention', + ) }) it('orders owned, available, and team work by the viewer hierarchy', () => { diff --git a/web/src/lib/starmap/priority.ts b/web/src/lib/starmap/priority.ts index 0fed7f1..bb0bda3 100644 --- a/web/src/lib/starmap/priority.ts +++ b/web/src/lib/starmap/priority.ts @@ -17,10 +17,10 @@ export function deriveWorkPriority( session: SessionState | null, currentIssue: number | null, ): WorkPriority { - if (ticket.status === 'resolved') return 'resolved' - if (ticket.status === 'out_of_scope') return 'out_of_scope' if (session === 'blocked' || session === 'dead') return 'attention' if (session === 'implementing' || ticket.num === currentIssue) return 'doing_now' + if (ticket.status === 'resolved') return 'resolved' + if (ticket.status === 'out_of_scope') return 'out_of_scope' if (ticket.assignedToViewer) { return ticket.readyForAgent ? 'my_next' : 'my_future' } diff --git a/web/src/lib/starmap/starmap.test.ts b/web/src/lib/starmap/starmap.test.ts index 8252c29..1b31db7 100644 --- a/web/src/lib/starmap/starmap.test.ts +++ b/web/src/lib/starmap/starmap.test.ts @@ -183,6 +183,27 @@ describe('the island seam', () => { expect(emitted.at(-1)).toBe(null) }) + it('uses the zoomed-out active radius floor in pointer hit testing', () => { + const ticket: Ticket = { + num: 1, + slug: '1', + title: 'Current work', + type: 'issue', + status: 'open', + blockedBy: [], + parentIssue: null, + frontier: false, + } + sm.setModel([ticket], {}, 1) + zoomOut(host) + const active = sm.screenOf(1)! + expect(sm.selectAtScreen(active.x + 19, active.y)).toBe(1) + + sm.setModel([ticket]) + const planning = sm.screenOf(1)! + expect(sm.selectAtScreen(planning.x + 19, planning.y)).toBe(null) + }) + it('gives subissues a larger invisible target without changing top-level targets', () => { sm.setModel([ { num: 16, slug: '16', title: 'Parent', type: 'issue', status: 'open', frontier: true, blockedBy: [], parentIssue: null }, From 4264b297dc365769223e82c913f784a16afeeb58 Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Sat, 8 Aug 2026 19:45:53 -0500 Subject: [PATCH 09/17] docs: design cached project layout transitions --- ...ject-layout-cache-and-transition-design.md | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-08-project-layout-cache-and-transition-design.md diff --git a/docs/superpowers/specs/2026-08-08-project-layout-cache-and-transition-design.md b/docs/superpowers/specs/2026-08-08-project-layout-cache-and-transition-design.md new file mode 100644 index 0000000..b80c44d --- /dev/null +++ b/docs/superpowers/specs/2026-08-08-project-layout-cache-and-transition-design.md @@ -0,0 +1,197 @@ +# Project Layout Cache and Transition Design + +**Date:** 2026-08-08 +**Status:** Approved in conversation; pending written-spec review + +## Problem + +Changing the selected project synchronously recomputes its deterministic star-map +layout on the browser main thread. Real-data measurements put this work at about +2.5 to 3.7 seconds for Idle Mind, 21 to 22 seconds for Encrydle, and 85 to 94 +seconds for Evolve. The ordinary force layout takes under 60 milliseconds; the +subissue-cluster candidate search consumes almost all remaining time. During that +search the browser cannot paint, update controls, or accept another selection. + +The layout is deterministic for a structure signature, but the renderer remembers +only the currently displayed signature. Returning to a previously viewed project +therefore repeats the same expensive calculation. + +## Product Decisions + +- Selecting a project updates the route and selected sidebar item immediately. +- An uncached project displays a textual loading state before layout begins. +- Layout computation runs outside the browser main thread. +- A completed layout is cached for the app session by its exact structure + signature. +- The loading state includes a whole-seconds stopwatch and a Cancel action. +- Cancel restores the last successfully displayed project. +- A critical layout failure also restores the last successfully displayed + project. +- A stale result from a canceled or superseded request can never replace the + current project. + +## Transition Model + +The application distinguishes three project identities: + +- **requested project:** the project named by the current route and highlighted + in the sidebar; +- **loading project:** the uncached project whose layout request is active; +- **committed project:** the most recent project whose constellation rendered + successfully. + +Clicking a project immediately changes the requested project. A cache hit commits +and displays the project without showing a loading transition. A cache miss keeps +the new route and selection visible while the map region displays its loading +state and starts a background layout request. + +Only the active request may commit. Each request has an opaque generation. Success, +failure, and cancellation handlers compare their generation with the active one +before changing UI state. Selecting a third project while another project loads +terminates the obsolete worker, clears its stopwatch, and starts or restores the +new request without briefly displaying the obsolete result. + +On successful layout, the application displays the constellation, records the +requested project as committed, and removes the loading state. On Cancel or a +critical worker/layout failure, it terminates the active work and routes back to +the committed project. A critical failure also presents a dismissible error notice; +user cancellation is not reported as an error. + +If initial startup has no committed project to restore, a critical failure leaves +the application shell and requested selection available with an error state and a +Retry action. Cancel remains available during an initial layout and returns to the +empty, no-project selection when there is no previous successful selection. + +## Loading State + +The map region displays this content for an uncached layout: + +> Charting {project name}… +> +> First load may take a moment. {N} seconds elapsed. +> +> Cancel + +The stopwatch starts at `0 seconds`, increments once per elapsed whole second, and +stops on success, cancellation, supersession, failure, or component destruction. +The project name and primary loading message use a polite live region. The changing +stopwatch text is visible but does not announce every tick to screen readers. Cancel +is a keyboard-accessible button and receives a descriptive accessible name. + +The loading state must be painted before expensive work begins. Worker execution +keeps the message, stopwatch, and Cancel action responsive throughout the first +layout. + +## Layout Worker and Cache + +A small layout module owns one deep interface: request deterministic coordinates +for a set of layout nodes. Its implementation hides worker lifecycle, request +generation, session caching, defensive coordinate copies, cancellation, and typed +failure results from the Svelte wrapper and canvas renderer. + +The module computes the existing `structureSignature` before dispatch. Its cache is +keyed by that exact signature, which already includes node numbers, workflow edges, +valid parent topology, and orbit-label titles. Status, assignment, work priority, +ordinary selection, and session animation do not invalidate coordinates. A topology +or orbit-title change produces a different signature and triggers a new layout. + +The cache lives only for the current application session. It does not write to disk +and does not change server models. Entries hold immutable coordinate snapshots; a +caller receives a defensive copy so renderer state cannot corrupt a later cache hit. + +Each cache miss uses a dedicated module worker created through Vite's worker URL +support. The worker imports the existing pure deterministic layout implementation, +computes coordinates, and posts either a typed success or a serializable failure. +Cancel and supersession terminate that worker, giving immediate cancellation even +while the collision search is CPU-bound. + +## Renderer and Application Responsibilities + +The layout module owns coordinate calculation and reuse. The star-map renderer +continues to own canvas nodes, edges, camera fit, selection, animation, and paint. +It receives precomputed coordinates when a structure changes instead of invoking +the expensive layout implementation itself. Status-only pushes continue using the +existing no-movement fast path. + +The Svelte star-map wrapper owns presentation of loading and initial error states, +the stopwatch lifecycle, and the active layout request. It reports `ready`, +`cancel`, and `critical failure` transition outcomes through a small interface. + +The application owns requested-versus-committed project routing. It records the +last successful selection and performs rollback after cancellation or critical +failure. This keeps navigation policy out of the renderer and worker. + +## Error Handling + +- Worker construction, execution, message decoding, or layout exceptions become a + typed critical failure. +- A critical failure with a committed project restores that project and shows a + dismissible notice naming the project that could not be charted. +- A critical failure without a committed project keeps the shell usable and offers + Retry. +- Cancel and supersession terminate work and cannot populate the cache. +- A result whose generation is no longer active is ignored even if termination + raced with worker completion. +- A malformed coordinate result never enters the cache or renderer. +- Cache hits are validated for the requested signature before use. +- Worker and timer resources are released on every terminal path and component + destruction. + +## Testing + +Use test-driven development at the public seams: + +1. Layout-module tests prove a first request invokes the worker and a second request + for the same signature returns defensive cached coordinates without invoking it. +2. Cache tests prove status-only changes reuse coordinates, structural and orbit-title + changes miss, and failures or cancellations are not cached. +3. Request-lifecycle tests prove cancellation terminates work, superseded results are + ignored, malformed results fail, and only the active generation can commit. +4. Star-map wrapper tests prove optimistic loading copy, `0 seconds`, whole-second + stopwatch progression, accessible Cancel, cleanup, ready display, and initial + Retry behavior. +5. Application tests prove immediate route/sidebar selection, committed-selection + tracking, Cancel rollback, critical-failure rollback and notice, and startup + failure without a rollback target. +6. Renderer tests prove precomputed positions produce the existing deterministic + coordinates and preserve status-only no-movement, selection, camera, and edge + behavior. +7. Run the complete frontend test, Svelte check, production web build, Rust format, + Clippy, and locked workspace test gates on native Windows. +8. Verify in a headed browser that a first Evolve visit shows a responsive stopwatch + and Cancel, a canceled visit restores the previous project, and a completed return + visit uses the cache without another loading transition. + +## Release Notes + +Add an `Unreleased` changelog entry stating that project changes now select +immediately, show cancellable timed layout progress on first load, and reuse cached +constellation coordinates on later visits. + +## Scope + +This slice changes browser-side project transitions and deterministic layout +execution. It does not change GitHub synchronization, server model publication, +project persistence, graph topology, layout geometry, work-priority semantics, +installed application files, or cross-session disk caching. It does not optimize the +cluster scoring algorithm itself; the worker preserves its exact output while session +caching prevents repeated computation. + +## Acceptance Criteria + +- Clicking a configured project immediately updates the selected route and sidebar. +- Every uncached layout displays the approved project-specific loading message, + visible whole-seconds stopwatch, and Cancel button before computation begins. +- The UI, stopwatch, and Cancel action remain responsive while layout runs. +- Cancel terminates the pending calculation and restores the last successfully + displayed project, or the empty selection when no project has succeeded yet. +- Critical failure restores the last successfully displayed project and presents a + useful error notice. +- Initial failure without a previous successful project presents Error and Retry + without breaking the shell. +- Returning to an unchanged, successfully laid-out project does not recompute layout + or display the first-load transition. +- Structural or orbit-title changes invalidate the relevant cached coordinates. +- Stale, canceled, failed, or malformed results never reach the canvas or cache. +- Existing deterministic geometry and complete native-Windows verification gates + remain green. From b7df2a9f1d9a3b6d8cfa988afd3b117fb8cb90e2 Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Sat, 8 Aug 2026 19:51:13 -0500 Subject: [PATCH 10/17] docs: plan cached project layout transitions --- ...-08-project-layout-cache-and-transition.md | 492 ++++++++++++++++++ ...ject-layout-cache-and-transition-design.md | 10 +- 2 files changed, 498 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-08-project-layout-cache-and-transition.md diff --git a/docs/superpowers/plans/2026-08-08-project-layout-cache-and-transition.md b/docs/superpowers/plans/2026-08-08-project-layout-cache-and-transition.md new file mode 100644 index 0000000..4533d4d --- /dev/null +++ b/docs/superpowers/plans/2026-08-08-project-layout-cache-and-transition.md @@ -0,0 +1,492 @@ +# Project Layout Cache and Transition 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 project selection immediate and responsive by computing uncached deterministic layouts in a cancellable worker, showing a timed first-load state, caching successful coordinates for the session, and restoring the last successful project after cancellation or critical failure. + +**Architecture:** Add a deep `LayoutLoader` module whose small interface returns either cached coordinates or a cancellable pending result. A dedicated Vite module worker preserves the existing pure layout geometry off the main thread. The Svelte star-map wrapper owns loading/error presentation and request lifecycle, while `App.svelte` owns requested-versus-committed routing and rollback. + +**Tech Stack:** Svelte 5 runes, TypeScript 6, Vite module workers, Vitest/jsdom, existing canvas `StarMap`, native Windows PowerShell, Vite+ (`vp`), Rust/Cargo workspace verification. + +## Global Constraints + +- Work only in `D:\tmp\stellr-issues-91-96` on `codex/issues-91-96-work-priority`; preserve the dirty primary checkout. +- Preserve the existing deterministic `computeLayout` output exactly; do not optimize or retune cluster geometry in this slice. +- Cache only successful coordinate snapshots for the current browser session, keyed by the exact existing `structureSignature`. +- An uncached request must run off the browser main thread and remain immediately cancellable. +- Route/sidebar selection changes optimistically; only success changes the committed project. +- Cancel and critical failure restore the last successful project. With no successful project, Cancel shows Canceled plus Retry and critical failure shows Error plus Retry. +- Ignore every canceled or superseded result, even when worker completion races termination. +- Maintain the append-only, newest-first `CHANGELOG.md` structure under `Unreleased`. +- Use native Windows commands and executables only; do not use WSL or Linux toolchains. +- Preserve the six existing uncommitted Rust review-fix files and stage only files belonging to each task. + +--- + +## File Map + +- Create `web/src/lib/starmap/layout-loader.ts`: cache, worker adapter interface, result validation, cancellation, and browser worker factory. +- Create `web/src/lib/starmap/layout-loader.test.ts`: real interface tests using a controlled worker adapter. +- Create `web/src/lib/starmap/layout.worker.ts`: worker entry point invoking the existing pure `computeLayout`. +- Modify `web/src/lib/starmap/starmap.ts`: accept already-computed positions when applying a new structure. +- Modify `web/src/lib/starmap/starmap.test.ts`: preserve synchronous fallback tests and prove supplied coordinates bypass computation. +- Create `web/src/lib/LayoutTransition.svelte`: accessible loading/stopwatch/Cancel and canceled/error/Retry presentation. +- Create `web/src/lib/LayoutTransition.test.ts`: copy, accessibility, actions, and stopwatch rendering. +- Modify `web/src/lib/StarMap.svelte`: coordinate async layout requests, timers, stale-result suppression, renderer application, and callbacks. +- Modify `web/src/lib/StarMap.test-host.svelte`: expose controlled prop transitions required by wrapper tests. +- Modify `web/src/lib/StarMap.test.ts`: loading, cache hit, timer, cancellation, retry, failure, and cleanup tests. +- Modify `web/src/App.svelte`: committed-project tracking and rollback policy. +- Modify `web/src/App.test.ts`: optimistic selection and application-level rollback tests with a controlled loader. +- Modify `CHANGELOG.md`: add the pending behavior to `Unreleased`. + +--- + +### Task 1: Cancellable worker-backed session layout cache + +**Files:** +- Create: `web/src/lib/starmap/layout-loader.ts` +- Create: `web/src/lib/starmap/layout-loader.test.ts` +- Create: `web/src/lib/starmap/layout.worker.ts` + +**Interfaces:** +- Consumes: `LayoutNode`, `Point`, `computeLayout`, and `structureSignature` from `web/src/lib/starmap/layout.ts`. +- Produces: + +```ts +export type LayoutPoints = Record + +export type LayoutOutcome = + | { kind: 'ready'; points: LayoutPoints } + | { kind: 'cancelled' } + | { kind: 'failed'; message: string } + +export type LayoutLoad = + | { kind: 'cached'; signature: string; points: LayoutPoints } + | { + kind: 'pending' + signature: string + result: Promise + cancel(): void + } + +export interface LayoutRequester { + load(nodes: LayoutNode[]): LayoutLoad +} + +export interface LayoutWorkerPort { + onmessage: ((event: MessageEvent) => void) | null + onerror: ((event: ErrorEvent) => void) | null + postMessage(message: { nodes: LayoutNode[] }): void + terminate(): void +} + +export class LayoutLoader implements LayoutRequester { + constructor(workerFactory: () => LayoutWorkerPort) + load(nodes: LayoutNode[]): LayoutLoad +} + +export const browserLayoutLoader: LayoutRequester +``` + +- [ ] **Step 1: Write failing cache and lifecycle tests** + +Create a controlled `LayoutWorkerPort` that records `postMessage`/`terminate` and can emit arbitrary messages or errors. Add separate tests proving: + +```ts +it('returns a defensive cached result after one successful worker layout') +it('does not invalidate coordinates for status-only data outside LayoutNode') +it('uses a new worker when structure or an orbit title changes') +it('terminates and resolves cancelled without caching') +it('turns worker errors and malformed coordinates into failed outcomes') +it('ignores a ready message that races after cancellation') +``` + +Use finite coordinates for every requested node as the validity rule. Prove defensive copying by mutating the first returned point and asserting the later cache hit retains the original value. + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```powershell +vp exec vitest run src/lib/starmap/layout-loader.test.ts --reporter=verbose +``` + +Expected: FAIL because `layout-loader.ts` and its interface do not exist. + +- [ ] **Step 3: Implement the minimal loader and worker** + +Implement `LayoutLoader.load` as follows: + +1. Compute `structureSignature(nodes)`. +2. Return cloned cached points synchronously when present. +3. Create one dedicated worker for a miss. +4. Return a pending result whose `cancel()` terminates once and resolves `{ kind: 'cancelled' }`. +5. On a valid success message, clone into the cache, terminate, and resolve ready with another clone. +6. On construction error, worker error, explicit failure message, missing node, or non-finite coordinate, terminate and resolve failed. +7. Guard every terminal path with one settled flag so racing messages do nothing. + +The worker receives `{ nodes }`, calls `computeLayout(nodes)`, and posts either: + +```ts +{ kind: 'ready', points } +{ kind: 'failed', message: String(error) } +``` + +Create the browser adapter with Vite's statically analyzable worker URL: + +```ts +new Worker(new URL('./layout.worker.ts', import.meta.url), { type: 'module' }) +``` + +- [ ] **Step 4: Run focused tests and frontend typecheck** + +Run: + +```powershell +vp exec vitest run src/lib/starmap/layout-loader.test.ts --reporter=verbose +vp run check +``` + +Expected: all focused tests PASS and Svelte/TypeScript report zero errors. + +- [ ] **Step 5: Commit Task 1** + +```powershell +git add -- web/src/lib/starmap/layout-loader.ts web/src/lib/starmap/layout-loader.test.ts web/src/lib/starmap/layout.worker.ts +git commit -m "feat(web): cache cancellable project layouts" +``` + +--- + +### Task 2: Let the renderer apply prepared deterministic coordinates + +**Files:** +- Modify: `web/src/lib/starmap/starmap.ts:350-430` +- Modify: `web/src/lib/starmap/starmap.test.ts` + +**Interfaces:** +- Consumes: `LayoutPoints` from Task 1. +- Produces this compatible renderer seam: + +```ts +setModel( + tickets: Ticket[], + sessions?: Record, + currentIssue?: number | null, + preparedLayout?: LayoutPoints, +): void +``` + +The fourth argument applies only when the structure signature changes. Existing direct renderer consumers may omit it and retain the synchronous deterministic fallback. + +- [ ] **Step 1: Write the failing renderer test** + +Add a test that spies on `computeLayout`, supplies distinctive finite points through the fourth argument, and asserts: + +```ts +expect(computeLayout).not.toHaveBeenCalled() +expect(sm.positions()).toEqual(preparedPoints) +``` + +Also repush status-only tickets without a fourth argument and prove the prepared positions remain unchanged. + +- [ ] **Step 2: Run the focused renderer test and verify RED** + +Run: + +```powershell +vp exec vitest run src/lib/starmap/starmap.test.ts -t "prepared deterministic coordinates" --reporter=verbose +``` + +Expected: FAIL because `setModel` ignores or does not accept prepared positions. + +- [ ] **Step 3: Implement the minimal compatible renderer change** + +At the existing structure-change branch, replace the unconditional layout call with: + +```ts +const pts = preparedLayout ?? computeLayout(layoutNodes) +``` + +Keep signature calculation, node construction, edge refresh, selection clearing, and camera refit unchanged. Do not move cache or worker knowledge into the renderer. + +- [ ] **Step 4: Run the complete star-map renderer suite** + +Run: + +```powershell +vp exec vitest run src/lib/starmap/starmap.test.ts src/lib/starmap/layout.test.ts src/lib/starmap/cluster-layout.test.ts --reporter=verbose +``` + +Expected: all tests PASS with identical existing geometry assertions. + +- [ ] **Step 5: Commit Task 2** + +```powershell +git add -- web/src/lib/starmap/starmap.ts web/src/lib/starmap/starmap.test.ts +git commit -m "feat(web): apply prepared constellation layouts" +``` + +--- + +### Task 3: Present responsive timed loading, Cancel, and Retry + +**Files:** +- Create: `web/src/lib/LayoutTransition.svelte` +- Create: `web/src/lib/LayoutTransition.test.ts` +- Modify: `web/src/lib/StarMap.svelte` +- Modify: `web/src/lib/StarMap.test-host.svelte` +- Modify: `web/src/lib/StarMap.test.ts` + +**Interfaces:** +- Consumes: `LayoutRequester`, `LayoutLoad`, and `LayoutOutcome` from Task 1; prepared renderer coordinates from Task 2. +- `StarMap.svelte` adds optional injected `layout` with default `browserLayoutLoader` and callbacks: + +```ts +layout?: LayoutRequester +ready?: (spaceId: string) => void +cancelled?: (spaceId: string) => void +failed?: (spaceId: string, message: string) => void +``` + +- `LayoutTransition.svelte` accepts: + +```ts +kind: 'loading' | 'cancelled' | 'error' +projectName: string +elapsedSeconds?: number +message?: string +cancel?: () => void +retry?: () => void +``` + +- [ ] **Step 1: Write failing presentation tests** + +Use fake timers and a controlled `LayoutRequester`. Add separate tests proving: + +```ts +it('shows Charting, the first-load message, 0 seconds, and accessible Cancel on a miss') +it('increments visible elapsed seconds once per second without a live timer announcement') +it('renders a cached layout without showing the transition') +it('cancels the active request and reports the project id') +it('suppresses a superseded request result') +it('applies ready coordinates and reports the project id') +it('shows Error and Retry after an unhandled initial failure') +it('shows Canceled and Retry when cancellation does not navigate away') +it('clears its interval and cancels work when destroyed') +``` + +The controlled requester must return cached points or expose a pending outcome resolver without mocking the Svelte component itself. + +- [ ] **Step 2: Run wrapper and transition tests and verify RED** + +Run: + +```powershell +vp exec vitest run src/lib/LayoutTransition.test.ts src/lib/StarMap.test.ts --reporter=verbose +``` + +Expected: FAIL because the transition module and asynchronous wrapper behavior are absent. + +- [ ] **Step 3: Implement `LayoutTransition.svelte`** + +Render the exact loading copy: + +```text +Charting {projectName}... +First load may take a moment. {elapsedSeconds} seconds elapsed. +Cancel +``` + +Use `role="status"` and `aria-live="polite"` for the stable title/message. Put the ticking value in a separate element with `aria-live="off"`. Use real ` + {:else if kind === 'cancelled'} +
+

Layout canceled

+

{projectName} was not charted.

+
+ + {:else} +
+

Could not chart {projectName}

+

{message}

+
+ + {/if} + + + diff --git a/web/src/lib/LayoutTransition.test.ts b/web/src/lib/LayoutTransition.test.ts new file mode 100644 index 0000000..b8c3a57 --- /dev/null +++ b/web/src/lib/LayoutTransition.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mount, unmount, type ComponentProps } from 'svelte' +import LayoutTransition from './LayoutTransition.svelte' + +const mounted: object[] = [] + +afterEach(async () => { + for (const component of mounted.splice(0)) await unmount(component) + document.body.innerHTML = '' +}) + +function render(props: ComponentProps): HTMLElement { + const target = document.createElement('div') + document.body.appendChild(target) + mounted.push(mount(LayoutTransition, { target, props })) + return target +} + +describe('LayoutTransition', () => { + it('shows project-specific first-load progress without announcing every stopwatch tick', () => { + const cancel = vi.fn() + const target = render({ + kind: 'loading', + projectName: 'Evolve', + elapsedSeconds: 12, + cancel, + }) + + expect(target.textContent).toContain('Charting Evolve...') + expect(target.textContent).toContain('First load may take a moment.') + expect(target.textContent).toContain('12 seconds elapsed.') + expect(target.querySelector('[role="status"]')).not.toBeNull() + expect(target.querySelector('[data-elapsed]')?.getAttribute('aria-live')).toBe('off') + + const button = target.querySelector( + 'button[aria-label="Cancel layout for Evolve"]', + )! + button.click() + expect(cancel).toHaveBeenCalledOnce() + }) + + it('shows retry actions for canceled and failed layouts', async () => { + const retryCanceled = vi.fn() + const canceled = render({ kind: 'cancelled', projectName: 'Evolve', retry: retryCanceled }) + expect(canceled.textContent).toContain('Layout canceled') + canceled.querySelector('button')!.click() + expect(retryCanceled).toHaveBeenCalledOnce() + + await unmount(mounted.shift()!) + canceled.remove() + + const retryFailed = vi.fn() + const failed = render({ + kind: 'error', + projectName: 'Evolve', + message: 'worker exploded', + retry: retryFailed, + }) + expect(failed.textContent).toContain('Could not chart Evolve') + expect(failed.textContent).toContain('worker exploded') + failed.querySelector('button')!.click() + expect(retryFailed).toHaveBeenCalledOnce() + }) +}) diff --git a/web/src/lib/StarMap.svelte b/web/src/lib/StarMap.svelte index 474dfa1..eb0fe74 100644 --- a/web/src/lib/StarMap.svelte +++ b/web/src/lib/StarMap.svelte @@ -1,7 +1,14 @@ -
+
+{#if transition !== null} +
+ +
+{/if} diff --git a/web/src/lib/StarMap.test-host.svelte b/web/src/lib/StarMap.test-host.svelte index 9fffed1..7765f7b 100644 --- a/web/src/lib/StarMap.test-host.svelte +++ b/web/src/lib/StarMap.test-host.svelte @@ -1,13 +1,22 @@ - + diff --git a/web/src/lib/StarMap.test.ts b/web/src/lib/StarMap.test.ts index 8b17a27..25c1131 100644 --- a/web/src/lib/StarMap.test.ts +++ b/web/src/lib/StarMap.test.ts @@ -8,6 +8,13 @@ import StarMap from './StarMap.svelte' import StarMapTestHost from './StarMap.test-host.svelte' import type { SpaceModel } from './model' import { StarMap as Renderer } from './starmap/starmap' +import { structureSignature, type LayoutNode } from './starmap/layout' +import type { + LayoutLoad, + LayoutOutcome, + LayoutPoints, + LayoutRequester, +} from './starmap/layout-loader' const mounted: object[] = [] @@ -18,9 +25,65 @@ afterEach(async () => { document.body.innerHTML = '' document.head.querySelectorAll('[data-test-app-css]').forEach((style) => style.remove()) document.documentElement.style.removeProperty('--map-background') + vi.useRealTimers() vi.restoreAllMocks() }) +function pointsFor(nodes: LayoutNode[]): LayoutPoints { + return Object.fromEntries( + nodes.map((node, index) => [node.num, { x: 100 + index * 80, y: 200 + index * 40 }]), + ) +} + +const immediateLayout: LayoutRequester = { + load(nodes): LayoutLoad { + return { + kind: 'cached', + signature: structureSignature(nodes), + points: pointsFor(nodes), + } + }, +} + +interface ControlledRequest { + nodes: LayoutNode[] + cancelCalls: number + resolve(outcome: LayoutOutcome): void +} + +class ControlledLayout implements LayoutRequester { + requests: ControlledRequest[] = [] + resolveCancellation = true + + load(nodes: LayoutNode[]): LayoutLoad { + let resolveOutcome!: (outcome: LayoutOutcome) => void + const request: ControlledRequest = { + nodes, + cancelCalls: 0, + resolve: (outcome) => resolveOutcome(outcome), + } + const result = new Promise((resolve) => { + resolveOutcome = resolve + }) + this.requests.push(request) + return { + kind: 'pending', + signature: structureSignature(nodes), + result, + cancel: () => { + request.cancelCalls++ + if (this.resolveCancellation) resolveOutcome({ kind: 'cancelled' }) + }, + } + } +} + +async function settle(): Promise { + await Promise.resolve() + await Promise.resolve() + flushSync() +} + function space(number: number): SpaceModel { return { id: 'teloverge-stellr', @@ -72,7 +135,7 @@ describe('StarMap wrapper', () => { const component = mount(StarMap, { target, - props: { space: space(42) }, + props: { space: space(42), layout: immediateLayout }, }) mounted.push(component) flushSync() @@ -87,7 +150,7 @@ describe('StarMap wrapper', () => { const component = mount(StarMapTestHost, { target, - props: { initialSpace: space(42) }, + props: { initialSpace: space(42), layout: immediateLayout }, }) mounted.push(component) flushSync() @@ -99,6 +162,7 @@ describe('StarMap wrapper', () => { [expect.objectContaining({ num: 99, slug: '99', title: 'Issue 99' })], {}, null, + expect.objectContaining({ 99: expect.any(Object) }), ) }) @@ -109,7 +173,7 @@ describe('StarMap wrapper', () => { const component = mount(StarMap, { target, - props: { space: space(42), currentIssue: 14 }, + props: { space: space(42), currentIssue: 14, layout: immediateLayout }, }) mounted.push(component) flushSync() @@ -118,6 +182,7 @@ describe('StarMap wrapper', () => { [expect.objectContaining({ num: 42 })], {}, 14, + expect.objectContaining({ 42: expect.any(Object) }), ) }) @@ -132,6 +197,7 @@ describe('StarMap wrapper', () => { space: space(42), selectedIssue: 42, select: (number) => selected.push(number), + layout: immediateLayout, }, }) mounted.push(component) @@ -147,7 +213,7 @@ describe('StarMap wrapper', () => { const component = mount(StarMapTestHost, { target, - props: { initialSpace: space(42), initialSelectedIssue: 42 }, + props: { initialSpace: space(42), initialSelectedIssue: 42, layout: immediateLayout }, }) mounted.push(component) flushSync() @@ -166,7 +232,11 @@ describe('StarMap wrapper', () => { const component = mount(StarMap, { target, - props: { space: space(42), select: (number) => selected.push(number) }, + props: { + space: space(42), + select: (number) => selected.push(number), + layout: immediateLayout, + }, }) mounted.push(component) @@ -181,4 +251,153 @@ describe('StarMap wrapper', () => { expect(selected).toEqual([42]) }) + + it('shows timed first-load progress while a layout is pending', async () => { + vi.useFakeTimers() + const layout = new ControlledLayout() + const target = document.createElement('div') + document.body.appendChild(target) + + const component = mount(StarMap, { target, props: { space: space(42), layout } }) + mounted.push(component) + flushSync() + + expect(target.textContent).toContain('Charting stellr...') + expect(target.textContent).toContain('0 seconds elapsed.') + expect(target.querySelector('.star-map')?.getAttribute('aria-hidden')).toBe('true') + + vi.advanceTimersByTime(2_000) + flushSync() + expect(target.textContent).toContain('2 seconds elapsed.') + }) + + it('applies ready coordinates and reports the successfully charted project', async () => { + const layout = new ControlledLayout() + const ready: string[] = [] + const setModel = vi.spyOn(Renderer.prototype, 'setModel') + const target = document.createElement('div') + document.body.appendChild(target) + + const component = mount(StarMap, { + target, + props: { space: space(42), layout, ready: (spaceId) => ready.push(spaceId) }, + }) + mounted.push(component) + flushSync() + + const points = { 42: { x: 12, y: 34 } } + layout.requests[0].resolve({ kind: 'ready', points }) + await settle() + + expect(setModel).toHaveBeenLastCalledWith( + [expect.objectContaining({ num: 42 })], + {}, + null, + points, + ) + expect(ready).toEqual(['teloverge-stellr']) + expect(target.textContent).not.toContain('First load may take a moment.') + expect(target.querySelector('.star-map')?.getAttribute('aria-hidden')).toBeNull() + }) + + it('cancels the active request and reports the project id', async () => { + const layout = new ControlledLayout() + const cancelled: string[] = [] + const target = document.createElement('div') + document.body.appendChild(target) + + const component = mount(StarMap, { + target, + props: { space: space(42), layout, cancelled: (spaceId) => cancelled.push(spaceId) }, + }) + mounted.push(component) + flushSync() + + target.querySelector('button[aria-label^="Cancel layout"]')!.click() + await settle() + + expect(layout.requests[0].cancelCalls).toBe(1) + expect(cancelled).toEqual(['teloverge-stellr']) + expect(target.textContent).toContain('Layout canceled') + expect(target.textContent).toContain('Retry') + }) + + it('ignores a superseded result and applies only the current project', async () => { + const layout = new ControlledLayout() + layout.resolveCancellation = false + const ready: string[] = [] + const setModel = vi.spyOn(Renderer.prototype, 'setModel') + const target = document.createElement('div') + document.body.appendChild(target) + + const component = mount(StarMapTestHost, { + target, + props: { + initialSpace: space(42), + layout, + ready: (spaceId) => ready.push(spaceId), + }, + }) + mounted.push(component) + flushSync() + + component.updateSpace({ ...space(99), id: 'teloverge-other', name: 'other' }) + flushSync() + expect(layout.requests[0].cancelCalls).toBe(1) + + layout.requests[0].resolve({ kind: 'ready', points: { 42: { x: 1, y: 2 } } }) + await settle() + expect(setModel).not.toHaveBeenCalled() + + layout.requests[1].resolve({ kind: 'ready', points: { 99: { x: 3, y: 4 } } }) + await settle() + expect(setModel).toHaveBeenCalledOnce() + expect(setModel.mock.calls[0]?.[0][0]?.num).toBe(99) + expect(ready).toEqual(['teloverge-other']) + }) + + it('shows an error with Retry after layout failure and starts fresh work on retry', async () => { + const layout = new ControlledLayout() + const failures: Array<[string, string]> = [] + const target = document.createElement('div') + document.body.appendChild(target) + + const component = mount(StarMap, { + target, + props: { + space: space(42), + layout, + failed: (spaceId, message) => failures.push([spaceId, message]), + }, + }) + mounted.push(component) + flushSync() + + layout.requests[0].resolve({ kind: 'failed', message: 'worker exploded' }) + await settle() + expect(failures).toEqual([['teloverge-stellr', 'worker exploded']]) + expect(target.textContent).toContain('Could not chart stellr') + expect(target.textContent).toContain('worker exploded') + + target.querySelector('button')!.click() + flushSync() + expect(layout.requests).toHaveLength(2) + expect(target.textContent).toContain('0 seconds elapsed.') + }) + + it('cancels pending work and clears its stopwatch when destroyed', async () => { + vi.useFakeTimers() + const layout = new ControlledLayout() + const target = document.createElement('div') + document.body.appendChild(target) + const clearInterval = vi.spyOn(window, 'clearInterval') + + const component = mount(StarMap, { target, props: { space: space(42), layout } }) + flushSync() + + await unmount(component) + + expect(layout.requests[0].cancelCalls).toBe(1) + expect(clearInterval).toHaveBeenCalled() + }) }) From 25e36275a7ad1e253e48be83fe72710ce5e6b982 Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Sat, 8 Aug 2026 20:03:48 -0500 Subject: [PATCH 14/17] feat(web): restore the last charted project --- web/src/App.svelte | 40 ++++++++++++ web/src/App.test.ts | 147 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 185 insertions(+), 2 deletions(-) diff --git a/web/src/App.svelte b/web/src/App.svelte index 53f1af9..e76ed1c 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -11,6 +11,10 @@ import { Route } from './lib/route.svelte' import { ThemeController } from './lib/theme.svelte' import { decideDock, type Dock } from './lib/starmap/dock' + import { + browserLayoutLoader, + type LayoutRequester, + } from './lib/starmap/layout-loader' import { beginDeviceAuthorization, cancelDeviceAuthorization, @@ -36,6 +40,8 @@ star: Model['spaces'][number]['stars'][number] | null } + let { layout = browserLayoutLoader }: { layout?: LayoutRequester } = $props() + function resolveRoute( modelSnapshot: Model | null, spaceId: string | null, @@ -76,6 +82,8 @@ let pendingRemovals = $state.raw>({}) let authStatus = $state(null) let nativeRouteNotice = $state(null) + let layoutFailureNotice = $state(null) + let committedSpaceId = $state(null) let nativeRouteBusy = false let persistedRouteKey: string | null = null const nativeRoutePersistence = hasNativeRoutePersistence() @@ -271,6 +279,28 @@ if (control.model !== null) reconcileModel(control.model) } + function committedLayout(spaceId: string): void { + if (route.space !== spaceId) return + committedSpaceId = spaceId + layoutFailureNotice = null + } + + function cancelledLayout(spaceId: string): void { + if (route.space !== spaceId) return + if (committedSpaceId !== null && committedSpaceId !== spaceId) { + route.go(committedSpaceId) + } + } + + function failedLayout(spaceId: string, message: string): void { + if (route.space !== spaceId) return + const projectName = spaces.find((space) => space.id === spaceId)?.name ?? spaceId + layoutFailureNotice = `Could not chart ${projectName}: ${message}` + if (committedSpaceId !== null && committedSpaceId !== spaceId) { + route.go(committedSpaceId) + } + } + onMount(() => { void theme.start() control.connect() @@ -309,6 +339,12 @@ {/if} + {#if layoutFailureNotice !== null} + + {/if}