From dd6b01b660f1291de7307b6da05eaf61d6e35ffe Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Tue, 29 Sep 2026 12:45:09 -0500 Subject: [PATCH 01/30] docs(roadmap): plan v0.12 Quorum and sprints; renumber later milestones --- ROADMAP.md | 90 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 75 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4157ab5..c4694fe 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -29,7 +29,8 @@ what to do next. branch names. Grouping by linked issues comes later. It's the last feature milestone, so it can slip to 1.1 without holding up 1.0. - **Four minor releases to 1.0:** v0.11 Sweep, v0.12 Sprint summary, v0.13 Related work, v0.14 - Release candidate. Fixes ship as patch releases in between. + Release candidate. Fixes ship as patch releases in between. *Superseded on 2026-09-29: five, + with v0.12 Quorum and sprints.* - **1.0 needs both** the feature milestones done and the auto-updater carrying installed copies through each of them. This replaces "1.0 when auto-update has proven itself across a few releases". Silent update install only landed in v0.10.3, so it has carried one update so far. @@ -55,6 +56,29 @@ what to do next. publishes the file as the release body and fails before building if it's missing. GitHub's generated notes were just a compare link. +## Decisions (2026-09-29) + +- **v0.12 is Quorum and sprints.** PR Sweep adopts the Quorum Design System, and named sprints + come back. The sprint summary moves to v0.13, so there are five minor releases to 1.0: v0.11 + Sweep, v0.12 Quorum and sprints, v0.13 Sprint summary, v0.14 Related work, v0.15 Release + candidate. +- **Quorum, adapted.** Its tokens are copied as-is, with one local change: `text-3` fails AA in + both themes (3.3–3.6:1), so it becomes `#636D7C` in light and `#8590A0` in dark. Manrope, IBM + Plex Mono and the Lucide icons are bundled, never loaded from a CDN, to keep the "only GitHub" + promise. Quorum's React components are the reference, rebuilt as a few small Angular components + only where a pattern repeats; everything else is plain markup on Quorum tokens. +- **The app and tray icons stay navy and gold.** Inside the app, the accent is Quorum's teal. +- **Quorum's voice applies everywhere**, release notes included: sentence case, plain words, no + emoji. Release notes keep their shape (a bold TL;DR, one-line bullets) under plain headings. +- **Sprints come back as a schedule, not a list.** They were removed on 2026-08-27 because + keeping a list of sprint windows up to date was "clicks for no benefit". A schedule (the first + sprint's number and start, a length, a name pattern, and one-off length or name overrides) + computes every sprint, and "Current" rolls over by itself. It's set per profile and included + in profile export. A custom range stays for one-off views. +- **The fixture sweep moves from the release candidate into v0.12**, so the redesign can be + reviewed in every state, including ones live data can't produce on demand. Running the + screenshots in CI stays with the release candidate. + ## v0.11 decisions (2026-09-25) - **The board shows all of the team's open PRs**, not only the ones updated in the range, so PRs @@ -155,8 +179,8 @@ clunky. - [x] Unit tests for core services (device-flow poll, config migration, query builder + bucketing) - [x] Tests run in CI on every PR and push - [x] Keyboard navigation / accessibility pass (focusable PR rows, dialog semantics, focus rings) -- Screenshot driver as a CI smoke test: *moved to v0.14, where a fixture sweep removes the - token-secret blocker* +- Screenshot driver as a CI smoke test: *moved to the release candidate (v0.15), where the + fixture sweep removes the token-secret blocker* ### v0.9: Platforms and performance ✅ - [x] Code signing (Azure Trusted Signing in the release workflow, same signing account as ez-money) @@ -259,13 +283,50 @@ the `release/v0.11` branch. to do, a PR leaves the list on its own once it's fixed, the first refresh after updating from v0.10.x fills in every row, and the budget holds with the numbers recorded here. -## v0.12: Sprint summary +## v0.12: Quorum and sprints + +Theme: PR Sweep looks and reads like one calm instrument, and knows which sprint it's in. Built +on the `release/v0.12` branch. + +- [ ] Roadmap, `AGENTS.md` and a draft pull request for the milestone. +- [ ] Fixture mode (`PRSWEEP_FIXTURE=`, unpackaged builds only): a canned sweep run through + the real attention engine, with no network. Fixtures `busy`, `calm`, `empty`, `error`, + `onboarding`, `update` and `loading` use relative times. A loader in `core/` has tests, + including one that `busy` triggers all nine attention reasons. +- [ ] The screenshot script shoots every fixture state in both themes, each run in its own + throwaway `--user-data-dir`. "Before" screenshots of the v0.11 UI go on the pull request. +- [ ] Foundation: Quorum tokens with the AA fix, bundled Manrope and IBM Plex Mono with their OFL + texts, and vendored Lucide icons with the ISC notice. `check-contrast` and `check-assets` + run in CI, each with a positive control. +- [ ] Sprints core: `core/sprints.ts` (schedule math, the current sprint, period resolution), + `sprints` and `period` on each profile with a migration, and a `period:resolve` IPC call. +- [ ] Shell: top bar with the sprint picker, freshness indicator, loading bar, tabs, theme + toggle, update pill and error banner. +- [ ] Board: health strip, filter bar, tables with density, CI status badges, Sweep rows, empty + states and skeleton rows. +- [ ] Settings, with a Sprints section, and onboarding. The old stylesheet is deleted and the + no-raw-colors rule switches on. +- [ ] Voice pass over every string, a Design section in `AGENTS.md`, and README screenshots from + the `busy` fixture. +- [ ] Review: before and after screenshots of every state in both themes, a keyboard pass, the + v0.11 snooze, quiet and tray checks rerun on fixtures, and the renderer bundle size + recorded. +- [ ] `chore(release): v0.12.0` with its notes, then mark the pull request ready, merge it, and + tag `v0.12.0`. + +**Done when:** every fixture state is restyled in both themes, with before and after screenshots +on the pull request; `check-contrast` and `check-assets` pass in CI; a profile with a sprint +schedule opens on the current sprint, rolls over by itself and navigates with the arrows, while +custom ranges and existing configs work unchanged; behavior and keyboard order match v0.11; and +the README shows the new screenshots. + +## v0.13: Sprint summary Theme: the board opens with how the sprint is going, and turns that into a standup in one click. -- [ ] Health strip above the board: merged, open, needs attention, and days left (only when the - range has an end date). -- [ ] Median time to merge for PRs merged in the range, from `mergedAt − createdAt`. No new +- [ ] The v0.12 health strip gains the sprint's story: merged, open, needs attention, and days + left in the sprint. +- [ ] Median time to merge for PRs merged in the sprint, from `mergedAt − createdAt`. No new fields. - [ ] **Copy standup as Markdown**: merged since the last working day, in review, blocked (CI failing, changes requested, conflicts), needs attention. @@ -274,7 +335,7 @@ Theme: the board opens with how the sprint is going, and turns that into a stand **Done when:** you can run a standup from the app and paste the summary into Slack or Teams without editing it. -## v0.13: Related work +## v0.14: Related work Theme: a feature that spans repos reads as one piece of work. @@ -287,24 +348,22 @@ Theme: a feature that spans repos reads as one piece of work. **Done when:** a feature that spans 3 repos shows as one expandable group with each PR's state. -## v0.14: Release candidate +## v0.15: Release candidate Theme: nothing new. Make what exists boringly reliable. - [ ] First-run, empty, loading and error states, checked end to end. That includes the SSO-blind token and the pending OAuth-app approval. -- [ ] Fixture sweep: the app can render a canned `SweepResult` without a token. -- [ ] The screenshot driver runs in CI against the fixture sweep (under `xvfb-run`), with no +- [ ] The screenshot script runs in CI against the fixture sweep (under `xvfb-run`), with no token secret needed. This is the item deferred from v0.8. -- [ ] Fresh README screenshots from the fixture sweep. - [ ] README repositioned around the sprint pitch and the 1.0 promise. - [ ] Config migration and profile export/import tested with every setting added since v0.11. -**Done when:** a 0.14.x build gets through a full sprint of daily use without needing a fix. +**Done when:** a 0.15.x build gets through a full sprint of daily use without needing a fix. ## v1.0: Stable -- [ ] v0.11 through v0.14 each reached installed copies (Windows installer and Linux AppImage) +- [ ] v0.11 through v0.15 each reached installed copies (Windows installer and Linux AppImage) through the auto-updater, with no manual reinstall. - [ ] The 1.0 promise holds. @@ -323,7 +382,8 @@ Theme: nothing new. Make what exists boringly reliable. - Grouping by linked issues (`closingIssuesReferences`) and GitHub Projects - Review-wait metrics for merged PRs (needs review timestamps on every merged PR, which is expensive) -- Roll the date range forward automatically each sprint (a sprint-length setting) +- Read sprints from a GitHub Projects iteration field, the closest thing GitHub has to Azure + DevOps iterations (needs the `read:project` token scope) - Post the standup straight to Slack or Teams ## Not planned From b133fa22b5831f97e215f723790eb64c7fe22e39 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Tue, 29 Sep 2026 12:45:10 -0500 Subject: [PATCH 02/30] docs(agents): release notes follow Quorum's voice, no emoji --- AGENTS.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1582a7c..55dee1b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,11 +121,12 @@ Run these from the repo root unless noted. version bump. The release workflow publishes that file as the release body and fails before building if it's missing. Write them for a reader skimming on a phone: - Open with one bold **TL;DR:** line saying what changed and why it matters. - - Then short sections, in this order, only when they have something: `## ✨ New`, - `## ⚡ Faster`, `## 🐛 Fixed`, `## 👀 Heads up` (anything a user might trip over), and - `## ⬆️ Getting it`. + - Then short sections, in this order, only when they have something: `## New`, + `## Faster`, `## Fixed`, `## Heads up` (anything a user might trip over), and + `## Getting it`. - One line per bullet, starting with a **bold** phrase. Plain words, user-visible effects, - real numbers when there are some. No commit hashes, no internals, no paragraphs. + real numbers when there are some. No commit hashes, no internals, no paragraphs, and no + emoji: Quorum's voice applies to release notes too. - Pushing a `v*` tag runs `.github/workflows/release.yml`. It builds the signed Windows installer, the portable exe and the Linux AppImage, then publishes one GitHub release. The `latest*.yml` files and blockmaps must ship with every release, because the auto-updater reads them. From 36949df30b6576d2bab51e7fd713847e6078832f Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Tue, 29 Sep 2026 12:51:55 -0500 Subject: [PATCH 03/30] test(fixtures): canned sweeps for every UI state and isolated screenshots --- AGENTS.md | 13 +- desktop/e2e/fixtures/busy.json | 41 ++++ desktop/e2e/fixtures/calm.json | 18 ++ desktop/e2e/fixtures/empty.json | 9 + desktop/e2e/fixtures/error.json | 1 + desktop/e2e/fixtures/loading.json | 1 + desktop/e2e/fixtures/onboarding.json | 4 + desktop/e2e/fixtures/update.json | 7 + desktop/e2e/screenshot.mjs | 269 +++++++++++++++---------- desktop/package.json | 2 +- desktop/src/main/core/fixture.test.mjs | 105 ++++++++++ desktop/src/main/core/fixture.ts | 150 ++++++++++++++ desktop/src/main/ipc.ts | 44 +++- desktop/src/main/main.ts | 19 ++ 14 files changed, 564 insertions(+), 119 deletions(-) create mode 100644 desktop/e2e/fixtures/busy.json create mode 100644 desktop/e2e/fixtures/calm.json create mode 100644 desktop/e2e/fixtures/empty.json create mode 100644 desktop/e2e/fixtures/error.json create mode 100644 desktop/e2e/fixtures/loading.json create mode 100644 desktop/e2e/fixtures/onboarding.json create mode 100644 desktop/e2e/fixtures/update.json create mode 100644 desktop/src/main/core/fixture.test.mjs create mode 100644 desktop/src/main/core/fixture.ts diff --git a/AGENTS.md b/AGENTS.md index 55dee1b..820bcd9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,9 +97,16 @@ Run these from the repo root unless noted. - `npm run package:win` builds the installer and portable exe. `npm run package:linux` builds the AppImage and has to run on Linux. Local packages are unsigned unless the Azure env vars are set. -- From `desktop/` after a build, `GH_TOKEN=$(gh auth token) node e2e/screenshot.mjs` writes - screenshots to `desktop/e2e/shots/`. `PRSWEEP_DEMO=1` points it at a public org for README - images. +- From `desktop/` after `npm run build`, `node e2e/screenshot.mjs --fixtures [busy,calm,…]` + shoots every fixture state in both themes to `desktop/e2e/shots/`, with no token or network. + `GH_TOKEN=$(gh auth token) PRSWEEP_ORG= node e2e/screenshot.mjs` shoots the live board. + Every run uses a throwaway `--user-data-dir` and aborts unless the app really uses it; never + point a UI script at the real data folder (Chromium ignores `%APPDATA%` overrides). +- `PRSWEEP_FIXTURE=` makes an unpackaged build serve canned sweeps from + `desktop/e2e/fixtures/.json` instead of GitHub, still judged by the real attention + engine. Fixture times are relative (`"-3d"`, `"+2d"`); rows list only what matters and the + loader (`core/fixture.ts`) fills the rest. `busy` must keep triggering all nine attention + reasons (its test checks). - `PRSWEEP_DEBUG=1` makes the main process log GraphQL variables and response bodies, plus one `[sweep]` line per sweep with its mode, duration, requests and retries. - From `desktop/` after `npm run build:main`, diff --git a/desktop/e2e/fixtures/busy.json b/desktop/e2e/fixtures/busy.json new file mode 100644 index 0000000..77aa799 --- /dev/null +++ b/desktop/e2e/fixtures/busy.json @@ -0,0 +1,41 @@ +{ + "profile": { + "org": "acme", + "authors": ["dana", "lee", "sam", "priya", "jo"], + "range": { "start": "-12d", "end": "+2d" }, + "includeDrafts": true, + "staleDays": 5 + }, + "open": [ + { "repo": "api", "number": 482, "title": "Refresh OAuth tokens before they expire", "author": "dana", "ci": "failure", "createdAt": "-1d", "updatedAt": "-5h", "lastCommitAt": "-7h", "requestedReviewers": ["lee"], "comments": 4, "additions": 212, "deletions": 38 }, + { "repo": "infra", "number": 94, "title": "Pool Redis connections per worker", "author": "sam", "bucket": "approved", "createdAt": "-6d", "updatedAt": "-3d", "approvedAt": "-3d", "mergeable": "conflicting", "reviewCount": 2, "comments": 6, "additions": 140, "deletions": 51 }, + { "repo": "web", "number": 812, "title": "Show clear login error states", "author": "priya", "bucket": "changes-requested", "createdAt": "-5d", "updatedAt": "-2d", "lastCommitAt": "-3d", "changesRequestedAt": "-2d", "mergeable": "mergeable", "reviewCount": 1, "comments": 9, "additions": 96, "deletions": 20 }, + { "repo": "web", "number": 799, "title": "Warn before the session times out", "author": "jo", "bucket": "changes-requested", "createdAt": "-7d", "updatedAt": "-2d", "lastCommitAt": "-2d", "changesRequestedAt": "-4d", "mergeable": "mergeable", "reviewCount": 2, "comments": 11, "additions": 58, "deletions": 12 }, + { "repo": "api", "number": 491, "title": "Add OAuth endpoints for service accounts", "author": "lee", "bucket": "approved", "createdAt": "-4d", "updatedAt": "-30h", "approvedAt": "-36h", "mergeable": "mergeable", "reviewCount": 2, "comments": 3, "additions": 318, "deletions": 44 }, + { "repo": "docs", "number": 201, "title": "Onboarding guide for new services", "author": "priya", "createdAt": "-5h", "updatedAt": "-5h", "requestCount": 0, "reviewCount": 0, "additions": 410, "deletions": 0 }, + { "repo": "web", "number": 830, "title": "Dark mode tokens for charts", "author": "sam", "ci": "pending", "createdAt": "-3h", "updatedAt": "-40m", "requestedReviewers": ["priya"], "additions": 77, "deletions": 31 }, + { "repo": "api", "number": 497, "title": "Return rate-limit headers on every response", "author": "jo", "createdAt": "-20h", "updatedAt": "-2h", "requestedReviewers": ["dana", "acme/platform"], "comments": 1, "additions": 64, "deletions": 9 }, + { "repo": "mobile", "number": 41, "title": "Cache the feed for offline use", "author": "dana", "bucket": "approved", "createdAt": "-2d", "updatedAt": "-4h", "approvedAt": "-4h", "mergeable": "mergeable", "reviewCount": 1, "additions": 250, "deletions": 70 }, + { "repo": "billing", "number": 57, "title": "Export invoices as PDF", "author": "lee", "createdAt": "-6d", "updatedAt": "-1d", "requestedReviewers": ["acme/payments"], "comments": 2, "additions": 180, "deletions": 15 }, + { "repo": "mobile", "number": 33, "title": "Ask before enabling push notifications", "author": "sam", "createdAt": "-9d", "updatedAt": "-7d", "requestedReviewers": ["jo"], "additions": 45, "deletions": 8 }, + { "repo": "api", "number": 455, "title": "Remove the legacy v1 routes", "author": "dana", "isDraft": true, "createdAt": "-12d", "updatedAt": "-8d", "additions": 20, "deletions": 1240 }, + { "repo": "web", "number": 835, "title": "Settings page layout experiment", "author": "priya", "isDraft": true, "createdAt": "-1d", "updatedAt": "-1d", "additions": 130, "deletions": 60 }, + { "repo": "infra", "number": 88, "title": "Upgrade the Terraform modules", "author": "jo", "ci": "failure", "createdAt": "-45d", "updatedAt": "-40d", "lastCommitAt": "-40d", "requestedReviewers": ["sam"], "comments": 5, "additions": 900, "deletions": 870 } + ], + "merged": [ + { "repo": "api", "number": 470, "title": "Paginate the audit log endpoint", "author": "lee", "createdAt": "-11d", "mergedAt": "-9d", "additions": 120, "deletions": 18 }, + { "repo": "web", "number": 790, "title": "Keyboard shortcuts for the review queue", "author": "priya", "createdAt": "-10d", "mergedAt": "-8d", "additions": 88, "deletions": 14 }, + { "repo": "infra", "number": 90, "title": "Alert when the queue backs up", "author": "sam", "createdAt": "-9d", "mergedAt": "-7d", "additions": 42, "deletions": 3 }, + { "repo": "billing", "number": 55, "title": "Retry failed card charges once", "author": "jo", "createdAt": "-8d", "mergedAt": "-6d", "additions": 77, "deletions": 21 }, + { "repo": "api", "number": 476, "title": "Tighten CORS for the admin API", "author": "dana", "createdAt": "-7d", "mergedAt": "-5d", "additions": 16, "deletions": 9 }, + { "repo": "mobile", "number": 38, "title": "Fix the crash on an empty inbox", "author": "sam", "createdAt": "-6d", "mergedAt": "-4d", "additions": 11, "deletions": 4 }, + { "repo": "docs", "number": 198, "title": "Document the release checklist", "author": "priya", "createdAt": "-5d", "mergedAt": "-3d", "additions": 140, "deletions": 0 }, + { "repo": "web", "number": 806, "title": "Lazy-load the settings bundle", "author": "jo", "createdAt": "-4d", "mergedAt": "-2d", "additions": 35, "deletions": 12 }, + { "repo": "api", "number": 488, "title": "Log slow queries over 500 ms", "author": "lee", "createdAt": "-3d", "mergedAt": "-1d", "additions": 29, "deletions": 2 }, + { "repo": "infra", "number": 92, "title": "Rotate the staging database password", "author": "dana", "createdAt": "-2d", "mergedAt": "-6h", "additions": 4, "deletions": 4 } + ], + "queue": [ + { "repo": "api", "number": 482, "title": "Refresh OAuth tokens before they expire", "author": "dana", "ci": "failure", "createdAt": "-1d", "updatedAt": "-5h", "reviewRequestedAt": "-26h", "requestedReviewers": ["lee", "you"], "comments": 4, "additions": 212, "deletions": 38 }, + { "repo": "billing", "number": 60, "title": "Handle refund webhooks", "author": "kai", "createdAt": "-4h", "updatedAt": "-3h", "reviewRequestedAt": "-3h", "requestedReviewers": ["you"], "additions": 66, "deletions": 5 } + ] +} diff --git a/desktop/e2e/fixtures/calm.json b/desktop/e2e/fixtures/calm.json new file mode 100644 index 0000000..82b9dd2 --- /dev/null +++ b/desktop/e2e/fixtures/calm.json @@ -0,0 +1,18 @@ +{ + "profile": { + "org": "acme", + "authors": ["dana", "lee", "sam", "priya", "jo"], + "range": { "start": "-12d", "end": null }, + "includeDrafts": false, + "staleDays": 5 + }, + "open": [ + { "repo": "web", "number": 830, "title": "Dark mode tokens for charts", "author": "sam", "ci": "pending", "createdAt": "-3h", "updatedAt": "-40m", "requestedReviewers": ["priya"] }, + { "repo": "api", "number": 497, "title": "Return rate-limit headers on every response", "author": "jo", "createdAt": "-20h", "updatedAt": "-2h", "requestedReviewers": ["dana"] }, + { "repo": "mobile", "number": 41, "title": "Cache the feed for offline use", "author": "dana", "bucket": "approved", "createdAt": "-2d", "updatedAt": "-4h", "approvedAt": "-4h", "mergeable": "mergeable", "reviewCount": 1 } + ], + "merged": [ + { "repo": "api", "number": 488, "title": "Log slow queries over 500 ms", "author": "lee", "createdAt": "-3d", "mergedAt": "-1d" }, + { "repo": "infra", "number": 92, "title": "Rotate the staging database password", "author": "dana", "createdAt": "-2d", "mergedAt": "-6h" } + ] +} diff --git a/desktop/e2e/fixtures/empty.json b/desktop/e2e/fixtures/empty.json new file mode 100644 index 0000000..bfa16d4 --- /dev/null +++ b/desktop/e2e/fixtures/empty.json @@ -0,0 +1,9 @@ +{ + "profile": { + "org": "acme", + "authors": ["dana", "lee", "sam", "priya", "jo"], + "range": { "start": "-1d", "end": null }, + "includeDrafts": false, + "staleDays": 5 + } +} diff --git a/desktop/e2e/fixtures/error.json b/desktop/e2e/fixtures/error.json new file mode 100644 index 0000000..426b02f --- /dev/null +++ b/desktop/e2e/fixtures/error.json @@ -0,0 +1 @@ +{ "extends": "busy", "failAfter": 1, "error": "GitHub API error: HTTP 502" } diff --git a/desktop/e2e/fixtures/loading.json b/desktop/e2e/fixtures/loading.json new file mode 100644 index 0000000..ce83f84 --- /dev/null +++ b/desktop/e2e/fixtures/loading.json @@ -0,0 +1 @@ +{ "extends": "busy", "delayMs": 120000 } diff --git a/desktop/e2e/fixtures/onboarding.json b/desktop/e2e/fixtures/onboarding.json new file mode 100644 index 0000000..b107909 --- /dev/null +++ b/desktop/e2e/fixtures/onboarding.json @@ -0,0 +1,4 @@ +{ + "auth": "no-token", + "profile": { "org": "", "authors": [], "range": { "start": "-12d", "end": null } } +} diff --git a/desktop/e2e/fixtures/update.json b/desktop/e2e/fixtures/update.json new file mode 100644 index 0000000..5f2440e --- /dev/null +++ b/desktop/e2e/fixtures/update.json @@ -0,0 +1,7 @@ +{ + "extends": "busy", + "update": [ + { "afterMs": 1500, "state": { "status": "downloading", "version": "0.12.1", "percent": 42 } }, + { "afterMs": 6000, "state": { "status": "ready", "version": "0.12.1", "percent": 100 } } + ] +} diff --git a/desktop/e2e/screenshot.mjs b/desktop/e2e/screenshot.mjs index 0b14c1a..1165808 100644 --- a/desktop/e2e/screenshot.mjs +++ b/desktop/e2e/screenshot.mjs @@ -1,129 +1,182 @@ /** - * Drives the built app for a visual check: launches Electron, completes - * onboarding with the token from GH_TOKEN if the modal is up, waits for the - * board to populate, and drops screenshots of the board (light + dark) and - * settings pages into e2e/shots/. + * Screenshots of the built app, for reviewing the UI and for the README. * - * Usage: GH_TOKEN=$(gh auth token) node e2e/screenshot.mjs + * node e2e/screenshot.mjs --fixtures [busy,calm,…] + * Every fixture state (e2e/fixtures) in both themes. No token, no network. + * GH_TOKEN=$(gh auth token) PRSWEEP_ORG= [PRSWEEP_AUTHORS=a,b] node e2e/screenshot.mjs + * The live board for an org, in both themes. * - * Demo mode (PRSWEEP_DEMO=1): temporarily points the app at a public org with - * real public activity — for README images — then restores the user's config. + * Run from desktop/ after `npm run build`. Output: e2e/shots/-.png. + * + * Every run gets a throwaway --user-data-dir and aborts unless the app really + * uses it, so your own config, token and snapshot are never touched. (Chromium + * finds %APPDATA% through a Windows API, so overriding the variable does nothing.) + * Selectors go by visible text, labels and roles, not classes, so the same + * script shoots the UI before and after a restyle. */ import { _electron } from 'playwright-core'; -import { mkdirSync } from 'fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { readFileSync } from 'node:fs'; +import { loadFixture } from '../dist/main/main/core/fixture.js'; -mkdirSync(new URL('./shots', import.meta.url), { recursive: true }); +const SHOTS = fileURLToPath(new URL('./shots/', import.meta.url)); +mkdirSync(SHOTS, { recursive: true }); -async function launch() { - const app = await _electron.launch({ args: ['.'] }); +async function launch(config, env) { + const userData = mkdtempSync(join(tmpdir(), 'prsweep-shots-')); + writeFileSync(join(userData, 'config.json'), JSON.stringify(config)); + const app = await _electron.launch({ + args: [`--user-data-dir=${userData}`, '.'], + env: { ...process.env, ELECTRON_RENDERER_URL: '', PRSWEEP_DEBUG: '', ...env }, + }); + const actual = await app.evaluate(({ app: a }) => a.getPath('userData')); + if (actual.toLowerCase() !== userData.toLowerCase()) { + await app.close(); + rmSync(userData, { recursive: true, force: true }); + throw new Error(`the app uses ${actual}, not the throwaway folder — aborting`); + } const win = await app.firstWindow(); - await win.waitForLoadState('domcontentloaded'); - // An occluded window can throttle rendering and stall page.screenshot — keep - // it frontmost and give captures a generous timeout. + // The app scrolls inside its main pane, so a full-page shot stops at the + // window's height: a tall viewport captures every section. + await win.setViewportSize({ width: 1440, height: 2400 }); + // An occluded window can throttle rendering and stall page.screenshot. await win.bringToFront().catch(() => void 0); win.setDefaultTimeout(60_000); - return { app, win }; + const close = async () => { + await app.close(); + rmSync(userData, { recursive: true, force: true }); + }; + return { win, close }; } -let { app, win } = await launch(); +function configFor(profile) { + return { + profiles: [{ id: 'shots', name: 'Acme team', ...profile }], + activeProfileId: 'shots', + autoRefreshMinutes: 0, + notifications: false, + closeToTray: false, + oauthClientId: '', + }; +} -// The modal only renders once the boot-time auth check (network) resolves — -// wait for it properly instead of a peek that races Angular's init. -const tokenInput = win.locator('.modal input[type="password"]'); -const modalUp = await tokenInput - .waitFor({ timeout: 15_000 }) - .then(() => true) - .catch(() => false); -if (modalUp) { - const token = process.env.GH_TOKEN; - if (!token) { - await win.screenshot({ path: 'e2e/shots/onboarding.png' }); - console.log('no GH_TOKEN — captured onboarding only'); - await app.close(); - process.exit(0); - } - const orgInput = win.locator('.modal .field input'); - if (!(await orgInput.inputValue())) { - const org = process.env.PRSWEEP_DEMO ? 'electron' : process.env.PRSWEEP_ORG; - if (!org) { - await win.screenshot({ path: 'e2e/shots/onboarding.png' }); - console.log('no org configured and none supplied — captured onboarding only'); - await app.close(); - process.exit(0); - } - await orgInput.fill(org); +/** Both themes of the current screen, as -light.png and -dark.png. */ +async function shoot(win, name) { + for (const theme of ['light', 'dark']) { + await win.evaluate((t) => (document.documentElement.dataset.theme = t), theme); + await win.waitForTimeout(200); + await win.screenshot({ path: join(SHOTS, `${name}-${theme}.png`), fullPage: true }); } - await tokenInput.fill(token); - await win.locator('.modal .btn-primary').click(); - // Modal closes once the token validates; the first sweep starts right after. - await win.locator('.modal').waitFor({ state: 'hidden', timeout: 30_000 }); + await win.evaluate(() => (document.documentElement.dataset.theme = 'light')); + console.log(` ${name}`); } -let savedConfig = null; -if (process.env.PRSWEEP_DEMO) { - savedConfig = await win.evaluate(() => window.api.getConfig()); - const day = 86_400_000; - const start = new Date(Date.now() - 30 * day).toISOString().slice(0, 10); - await win.evaluate( - (c) => window.api.setConfig(c), - { - profiles: [ - { - id: 'demo', - name: 'Electron', - org: 'electron', - authors: ['MarshallOfSound', 'codebytere', 'deepak1556', 'YUCLing', 'Bloomca'], - range: { start, end: null }, - includeDrafts: false, - staleDays: 5, - }, - { - id: 'demo2', - name: 'Electron — infra', - org: 'electron', - authors: ['deepak1556'], - range: { start, end: null }, - includeDrafts: false, - staleDays: 5, - }, - ], - activeProfileId: 'demo', - autoRefreshMinutes: 5, - notifications: true, - closeToTray: true, - oauthClientId: '', - }, - ); - // Relaunch so the app boots cleanly from the demo config (reload() doesn't - // survive the file:// + hash-routing combo in the packaged renderer). - await app.close(); - ({ app, win } = await launch()); - // Deterministic: drive an explicit sweep rather than racing the boot one. - await win.locator('.header-status, .btn-primary').first().waitFor({ timeout: 20_000 }); - await win.locator('.btn-primary').click(); +async function boardReady(win) { + await win.locator('#sweep-title').waitFor(); + await win.waitForTimeout(800); } -// Board is populated once any PR row renders (or give up and shoot anyway). -await win - .locator('td.pr-ref') - .first() - .waitFor({ timeout: 60_000 }) - .catch(() => console.warn('no PR rows appeared — screenshotting as-is')); -await win.evaluate(() => { - localStorage.setItem('prsweep-theme', 'light'); - document.documentElement.dataset.theme = 'light'; -}); -await win.screenshot({ path: 'e2e/shots/board.png' }); +const button = (win, name) => win.getByRole('button', { name }); -await win.evaluate(() => (document.documentElement.dataset.theme = 'dark')); -await win.waitForTimeout(200); -await win.screenshot({ path: 'e2e/shots/board-dark.png' }); -await win.evaluate(() => (document.documentElement.dataset.theme = 'light')); +/** What to capture for each fixture, beyond launching it. */ +const STATES = { + async busy(win) { + await boardReady(win); + await shoot(win, 'busy'); + await button(win, /^Snooze /).first().click(); + await button(win, /Show snoozed/).click(); + await shoot(win, 'busy-snoozed'); + await button(win, /Show snoozed/).click(); + await button(win, /Show quiet/).click(); + await shoot(win, 'busy-quiet'); + await button(win, /Show quiet/).click(); + await win.getByPlaceholder('Filter by title or repo').fill('zzz'); + await shoot(win, 'busy-filtered'); + await win.getByPlaceholder('Filter by title or repo').fill(''); + await win.locator('a', { hasText: 'Settings' }).first().click(); + await win.waitForTimeout(500); + await shoot(win, 'settings'); + }, + async calm(win) { + await boardReady(win); + await shoot(win, 'calm'); + }, + async empty(win) { + await boardReady(win); + await shoot(win, 'empty'); + }, + async error(win) { + await boardReady(win); + await win.getByRole('button', { name: 'Refresh', exact: true }).click(); + await win.getByText('HTTP 502').first().waitFor(); + await shoot(win, 'error'); + }, + async onboarding(win) { + await win.getByRole('dialog').waitFor(); + await win.waitForTimeout(500); + await shoot(win, 'onboarding'); + }, + async update(win) { + await win.getByText(/0\.12\.1/).first().waitFor(); + await shoot(win, 'update-downloading'); + await button(win, /Restart/).waitFor(); + await shoot(win, 'update-ready'); + }, + async loading(win) { + await win.waitForTimeout(1500); + await shoot(win, 'loading'); + }, +}; -await win.locator('a.nav-link', { hasText: 'Settings' }).click(); -await win.waitForTimeout(400); -await win.screenshot({ path: 'e2e/shots/settings.png' }); +async function shootFixtures(names) { + const read = (n) => JSON.parse(readFileSync(new URL(`./fixtures/${n}.json`, import.meta.url), 'utf8')); + for (const name of names) { + const fixture = loadFixture(name, read, Date.now()); + const { win, close } = await launch(configFor(fixture.profile), { PRSWEEP_FIXTURE: name }); + console.log(name); + try { + await STATES[name](win); + } finally { + await close(); + } + } +} -if (savedConfig) await win.evaluate((c) => window.api.setConfig(c), savedConfig); -await app.close(); -console.log('screenshots written to e2e/shots/'); +async function shootLive() { + const org = process.env.PRSWEEP_ORG; + if (!process.env.GH_TOKEN || !org) { + console.error('Live mode needs GH_TOKEN and PRSWEEP_ORG (or use --fixtures).'); + process.exit(1); + } + const start = new Date(Date.now() - 14 * 86_400_000).toISOString().slice(0, 10); + const authors = process.env.PRSWEEP_AUTHORS ? process.env.PRSWEEP_AUTHORS.split(',') : []; + const { win, close } = await launch( + configFor({ org, authors, range: { start, end: null }, includeDrafts: false, staleDays: 5 }), + { PRSWEEP_FIXTURE: '' }, + ); + try { + await win.getByRole('dialog').waitFor(); + const tokenLink = win.getByText('use a personal access token'); + if (await tokenLink.count()) await tokenLink.click(); + await win.locator('input[type="password"]').fill(process.env.GH_TOKEN); + await button(win, /Connect/).click(); + await win.getByRole('dialog').waitFor({ state: 'hidden' }); + await boardReady(win); + await win.waitForTimeout(3000); + await shoot(win, 'live'); + } finally { + await close(); + } +} + +const i = process.argv.indexOf('--fixtures'); +if (i >= 0) { + const list = process.argv[i + 1] && !process.argv[i + 1].startsWith('--') ? process.argv[i + 1].split(',') : Object.keys(STATES); + await shootFixtures(list); +} else { + await shootLive(); +} +console.log(`screenshots in ${SHOTS}`); diff --git a/desktop/package.json b/desktop/package.json index 5e00b03..7814a68 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -7,7 +7,7 @@ "main": "dist/main/main/main.js", "scripts": { "build:main": "tsc -p tsconfig.json", - "test": "node src/main/core/oauth.service.test.mjs && node src/main/core/config.service.test.mjs && node src/main/core/github.service.test.mjs && node src/main/core/snapshot.store.test.mjs && node src/main/core/attention.test.mjs && node src/main/core/tray.text.test.mjs", + "test": "node src/main/core/oauth.service.test.mjs && node src/main/core/config.service.test.mjs && node src/main/core/github.service.test.mjs && node src/main/core/snapshot.store.test.mjs && node src/main/core/attention.test.mjs && node src/main/core/tray.text.test.mjs && node src/main/core/fixture.test.mjs", "start": "electron .", "package": "electron-builder", "package:win": "electron-builder --win", diff --git a/desktop/src/main/core/fixture.test.mjs b/desktop/src/main/core/fixture.test.mjs new file mode 100644 index 0000000..b6a7158 --- /dev/null +++ b/desktop/src/main/core/fixture.test.mjs @@ -0,0 +1,105 @@ +/** + * Verifies fixture loading: relative times resolve against "now", rows get + * defaults, bad fixtures fail loudly, `extends` layers one fixture on another, + * and the showcase fixture really triggers every attention reason. + * Run after `npm run build:main`: node src/main/core/fixture.test.mjs + */ +import assert from 'node:assert'; +import { readFileSync } from 'node:fs'; +import { annotate } from '../../../dist/main/main/core/attention.js'; +import { loadFixture, relativeDate, relativeTime } from '../../../dist/main/main/core/fixture.js'; + +const NOW = Date.parse('2026-09-29T15:00:00Z'); +const HOUR = 3_600_000; +const localDate = (ms) => { + const d = new Date(ms); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +}; + +// --- relative times and dates --- +assert.equal(relativeTime('-3d', NOW), new Date(NOW - 72 * HOUR).toISOString()); +assert.equal(relativeTime('-2h', NOW), new Date(NOW - 2 * HOUR).toISOString()); +assert.equal(relativeTime('+30m', NOW), new Date(NOW + 30 * 60_000).toISOString()); +assert.equal(relativeTime('2026-01-02T03:04:05Z', NOW), '2026-01-02T03:04:05Z', 'absolute passes through'); +assert.equal(relativeDate('+2d', NOW), localDate(NOW + 48 * HOUR), 'local calendar date'); +assert.equal(relativeDate('2026-09-14', NOW), '2026-09-14'); +assert.throws(() => relativeTime('yesterday', NOW), /"yesterday" is not a relative time/); + +// --- rows get defaults; times resolve --- +const read = (files) => (name) => { + if (!(name in files)) throw new Error(`no fixture ${name}`); + return files[name]; +}; +{ + const f = loadFixture( + 'one', + read({ + one: { + profile: { org: 'acme', authors: ['dana'], range: { start: '-13d', end: '+2d' } }, + open: [{ repo: 'api', number: 7, title: 'Add a thing', createdAt: '-2d', requestedReviewers: ['lee', 'acme/platform'] }], + }, + }), + NOW, + ); + const [row] = f.sweep.open; + assert.equal(row.url, 'https://github.com/acme/api/pull/7'); + assert.equal(row.bucket, 'needs-review'); + assert.equal(row.createdAt, new Date(NOW - 48 * HOUR).toISOString()); + assert.equal(row.lastCommitAt, row.createdAt, 'last commit defaults to creation'); + assert.equal(row.requestCount, 2, 'request count follows the reviewers listed'); + assert.deepEqual(row.attention, []); + assert.strictEqual(row.mergeable, null); + assert.equal(f.profile.range.start, localDate(NOW - 13 * 24 * HOUR)); + assert.equal(f.auth, 'signed-in'); + assert.deepEqual(f.sweep.merged, []); +} + +// --- bad rows fail with a message that names the fixture and the field --- +assert.throws( + () => + loadFixture( + 'bad', + read({ bad: { profile: { org: 'acme', authors: [], range: { start: '-1d', end: null } }, open: [{ repo: 'api', number: 1, title: 'x', bucket: 'stuck' }] } }), + NOW, + ), + /bad: open\[0\]\.bucket "stuck" is not one of/, +); + +// --- extends: the base fixture, with the extending one's fields on top --- +{ + const f = loadFixture( + 'err', + read({ + base: { profile: { org: 'acme', authors: [], range: { start: '-1d', end: null } }, open: [{ repo: 'api', number: 1, title: 'x' }] }, + err: { extends: 'base', failAfter: 1, error: 'GitHub API error: HTTP 502' }, + }), + NOW, + ); + assert.equal(f.sweep.open.length, 1, 'rows come from the base'); + assert.equal(f.failAfter, 1); + assert.equal(f.error, 'GitHub API error: HTTP 502'); +} + +// --- the busy fixture triggers every attention reason --- +{ + const files = (name) => JSON.parse(readFileSync(new URL(`../../../e2e/fixtures/${name}.json`, import.meta.url), 'utf8')); + const f = loadFixture('busy', files, Date.now()); + const judged = annotate( + { schema: 0, fetchedAt: '', org: f.profile.org, range: f.profile.range, ...f.sweep, sprintRisk: null }, + { now: Date.now(), staleDays: f.profile.staleDays, rangeEnd: f.profile.range.end }, + ); + const reasons = new Set(judged.open.flatMap((r) => r.attention.map((a) => a.reason))); + assert.deepEqual( + [...reasons].sort(), + [ + 'APPROVED_NOT_MERGED', 'CHANGES_NOT_ADDRESSED', 'CI_FAILING', 'DRAFT_TOO_LONG', 'MERGE_CONFLICT', + 'NEEDS_RE_REVIEW', 'NO_REVIEWERS', 'STALE', 'WAITING_FOR_REVIEW', + ], + 'busy covers all nine reasons', + ); + assert.ok(judged.open.some((r) => r.quiet), 'busy has a quiet tail'); + assert.ok(judged.open.some((r) => r.attention.length === 0), 'busy has healthy rows too'); + assert.ok(judged.sprintRisk, 'busy ends its range within two days'); +} + +console.log('fixture: relative times, defaults, validation, extends and busy coverage pass'); diff --git a/desktop/src/main/core/fixture.ts b/desktop/src/main/core/fixture.ts new file mode 100644 index 0000000..dc94cd7 --- /dev/null +++ b/desktop/src/main/core/fixture.ts @@ -0,0 +1,150 @@ +/** + * Fixture mode: a canned sweep for reviewing the UI in every state without + * GitHub or a token (PRSWEEP_FIXTURE=, unpackaged builds only). Fixture + * files live in e2e/fixtures/ and write times relative to now ("-3d", "-2h", + * "+2d"), so ages and dates stay right whenever the screenshots are taken. + * Rows list only what matters to them; everything else gets a default. Pure: + * the caller supplies how a fixture name is read. + */ +import { PrRow, ReviewBucket, UpdateState } from '../../shared/types'; + +export interface FixtureProfile { + org: string; + authors: string[]; + range: { start: string; end: string | null }; + includeDrafts: boolean; + staleDays: number; +} + +export interface Fixture { + /** Seeded into config.json by the screenshot script. */ + profile: FixtureProfile; + /** 'no-token' shows onboarding. */ + auth: 'signed-in' | 'no-token'; + sweep: { open: PrRow[]; merged: PrRow[]; queue: PrRow[] }; + /** Sweeps after this many succeed fail with `error`. */ + failAfter: number | null; + error: string; + /** Hold every sweep this long: the loading state. */ + delayMs: number; + /** Header update states, pushed `afterMs` after the window loads. */ + update: Array<{ afterMs: number; state: UpdateState | null }>; +} + +type Json = Record; + +const BUCKETS: ReviewBucket[] = ['needs-review', 'changes-requested', 'approved', 'merged']; +const CI = ['success', 'failure', 'pending', null]; +const MERGEABLE = ['mergeable', 'conflicting', 'unknown', null]; +const TIME_FIELDS = ['createdAt', 'updatedAt', 'mergedAt', 'lastCommitAt', 'reviewRequestedAt', 'approvedAt', 'changesRequestedAt'] as const; +const UNIT_MS: Record = { m: 60_000, h: 3_600_000, d: 86_400_000 }; + +function offsetMs(value: string): number | null { + const m = /^([+-])(\d+(?:\.\d+)?)([mhd])$/.exec(value); + return m ? (m[1] === '-' ? -1 : 1) * Number(m[2]) * UNIT_MS[m[3]] : null; +} + +/** "-3d" → an ISO timestamp that long before `now`. Absolute timestamps pass through. */ +export function relativeTime(value: string, now: number): string { + const offset = offsetMs(value); + if (offset !== null) return new Date(now + offset).toISOString(); + if (!Number.isNaN(Date.parse(value))) return value; + throw new Error(`"${value}" is not a relative time like -3d, -2h or +30m`); +} + +/** "+2d" → the local calendar date (yyyy-mm-dd) that far from `now`, like the header's date inputs. */ +export function relativeDate(value: string, now: number): string { + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return value; + const offset = offsetMs(value); + if (offset === null) throw new Error(`"${value}" is not a date or a relative date like +2d`); + const d = new Date(now + offset); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +/** Load fixture `name`, resolving `extends` through `read` (which returns the parsed JSON for a name). */ +export function loadFixture(name: string, read: (name: string) => unknown, now: number): Fixture { + const raw = flatten(name, read, new Set()); + const where = (path: string): string => `${name}: ${path}`; + const profile = (raw.profile ?? {}) as Json; + const range = (profile.range ?? {}) as Json; + if (typeof range.start !== 'string') throw new Error(where('profile.range.start is required')); + const org = String(profile.org ?? ''); + const rows = (key: 'open' | 'merged' | 'queue', bucket: ReviewBucket): PrRow[] => + ((raw[key] ?? []) as Json[]).map((r, i) => toRow(r, `${key}[${i}]`, org, bucket, now, where)); + return { + profile: { + org, + authors: (profile.authors as string[]) ?? [], + range: { + start: relativeDate(range.start, now), + end: typeof range.end === 'string' ? relativeDate(range.end, now) : null, + }, + includeDrafts: Boolean(profile.includeDrafts), + staleDays: typeof profile.staleDays === 'number' ? profile.staleDays : 5, + }, + auth: raw.auth === 'no-token' ? 'no-token' : 'signed-in', + sweep: { open: rows('open', 'needs-review'), merged: rows('merged', 'merged'), queue: rows('queue', 'needs-review') }, + failAfter: typeof raw.failAfter === 'number' ? raw.failAfter : null, + error: String(raw.error ?? 'GitHub API error: HTTP 502'), + delayMs: typeof raw.delayMs === 'number' ? raw.delayMs : 0, + update: ((raw.update ?? []) as Array<{ afterMs: number; state: UpdateState | null }>).map((u) => ({ ...u })), + }; +} + +/** The fixture with its `extends` chain applied: the base first, each extending file's keys on top. */ +function flatten(name: string, read: (name: string) => unknown, seen: Set): Json { + if (seen.has(name)) throw new Error(`${name}: extends itself`); + seen.add(name); + const own = read(name) as Json; + if (typeof own.extends !== 'string') return own; + const { extends: base, ...rest } = own; + return { ...flatten(base as string, read, seen), ...rest }; +} + +function toRow(r: Json, path: string, org: string, fallbackBucket: ReviewBucket, now: number, where: (p: string) => string): PrRow { + const oneOf = (field: string, allowed: unknown[], fallback: unknown): unknown => { + const value = field in r ? r[field] : fallback; + if (!allowed.includes(value)) { + throw new Error(where(`${path}.${field} ${JSON.stringify(value)} is not one of ${allowed.map((a) => JSON.stringify(a)).join(', ')}`)); + } + return value; + }; + if (typeof r.repo !== 'string' || typeof r.number !== 'number' || typeof r.title !== 'string') { + throw new Error(where(`${path} needs repo, number and title`)); + } + const times: Record = {}; + for (const field of TIME_FIELDS) { + const v = r[field]; + times[field] = typeof v === 'string' ? relativeTime(v, now) : null; + } + const createdAt = times.createdAt ?? relativeTime('-2h', now); + const reviewers = (r.requestedReviewers as string[]) ?? []; + return { + repo: r.repo, + number: r.number, + title: r.title, + url: `https://github.com/${org}/${r.repo}/pull/${r.number}`, + isDraft: Boolean(r.isDraft), + author: String(r.author ?? 'dana'), + authorAvatarUrl: '', + bucket: oneOf('bucket', BUCKETS, fallbackBucket) as ReviewBucket, + createdAt, + updatedAt: times.updatedAt ?? createdAt, + mergedAt: times.mergedAt, + comments: typeof r.comments === 'number' ? r.comments : 0, + additions: typeof r.additions === 'number' ? r.additions : 24, + deletions: typeof r.deletions === 'number' ? r.deletions : 6, + requestedReviewers: reviewers, + requestCount: typeof r.requestCount === 'number' ? r.requestCount : reviewers.length, + // Merged rows never carry CI in a real sweep (their search skips it). + ci: oneOf('ci', CI, fallbackBucket === 'merged' ? null : 'success') as PrRow['ci'], + lastCommitAt: times.lastCommitAt ?? createdAt, + reviewRequestedAt: times.reviewRequestedAt, + mergeable: oneOf('mergeable', MERGEABLE, null) as PrRow['mergeable'], + approvedAt: times.approvedAt, + changesRequestedAt: times.changesRequestedAt, + reviewCount: typeof r.reviewCount === 'number' ? r.reviewCount : null, + attention: [], + quiet: false, + }; +} diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 065535a..4eb9156 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -10,25 +10,28 @@ import { dialog, ipcMain, shell } from 'electron'; import { readFileSync, writeFileSync } from 'fs'; import { annotate } from './core/attention'; import { activeProfile, ConfigService } from './core/config.service'; +import { Fixture } from './core/fixture'; import { GithubService, SweepStats } from './core/github.service'; import { DEFAULT_OAUTH_CLIENT_ID } from './core/oauth.constants'; import { pollForToken, requestDeviceCode } from './core/oauth.service'; import { SnapshotStore } from './core/snapshot.store'; import { TokenStore } from './core/token.store'; -import { AuthStatus, DateRange, Profile, SweepConfigPatch } from '../shared/types'; +import { AuthStatus, DateRange, Profile, SWEEP_SCHEMA, SweepConfigPatch, SweepResult } from '../shared/types'; export interface Services { config: ConfigService; tokens: TokenStore; github: GithubService; snapshots: SnapshotStore; + /** Fixture mode (see core/fixture.ts): sweeps and sign-in come from here, never GitHub. */ + fixture: Fixture | null; } export function registerIpc(services: Services): void { ipcMain.handle('config:get', () => services.config.get()); ipcMain.handle('config:set', (_e, patch: SweepConfigPatch) => services.config.set(patch ?? {})); - ipcMain.handle('auth:status', () => authStatus(services)); + ipcMain.handle('auth:status', () => (services.fixture ? fixtureAuth(services.fixture) : authStatus(services))); ipcMain.handle('auth:setToken', async (_e, token: string) => { services.tokens.set(String(token ?? '').trim()); const status = await authStatus(services); @@ -59,22 +62,26 @@ export function registerIpc(services: Services): void { ipcMain.handle('prs:fetch', async (_e, range: DateRange, mode?: 'full' | 'auto') => { // Auto-refreshes may patch the cached snapshot incrementally; manual // refreshes always resweep in full so the user has a recovery lever. - const base = mode === 'auto' ? services.snapshots.get() : null; + const base = mode === 'auto' && !services.fixture ? services.snapshots.get() : null; try { const config = services.config.get(); + const swept = services.fixture + ? await fixtureSweep(services.fixture, range) + : await services.github.sweep(config, range, base); // Every sweep re-judges every open row, cached ones included. - const result = annotate(await services.github.sweep(config, range, base), { + const result = annotate(swept, { now: Date.now(), staleDays: activeProfile(config).staleDays, rangeEnd: range.end, }); - services.snapshots.set(result); + if (!services.fixture) services.snapshots.set(result); return result; } finally { - if (process.env.PRSWEEP_DEBUG) logSweep(mode ?? 'full', services.github.lastSweep); + if (process.env.PRSWEEP_DEBUG && !services.fixture) logSweep(mode ?? 'full', services.github.lastSweep); } }); - ipcMain.handle('prs:latest', () => services.snapshots.get()); + // Fixture mode starts empty, so the first sweep's loading state is visible. + ipcMain.handle('prs:latest', () => (services.fixture ? null : services.snapshots.get())); ipcMain.handle('shell:open', (_e, url: string) => { // The renderer only ever passes PR URLs, but shell.openExternal is the one @@ -116,6 +123,29 @@ export function registerIpc(services: Services): void { }); } +function fixtureAuth(fixture: Fixture): AuthStatus { + return fixture.auth === 'no-token' + ? { hasToken: false, login: null, error: null } + : { hasToken: true, login: 'you', error: null }; +} + +let fixtureSweeps = 0; + +/** The fixture's rows as a sweep: held for `delayMs`, failing once `failAfter` sweeps succeeded. */ +async function fixtureSweep(fixture: Fixture, range: DateRange): Promise { + fixtureSweeps++; + if (fixture.delayMs) await new Promise((r) => setTimeout(r, fixture.delayMs)); + if (fixture.failAfter !== null && fixtureSweeps > fixture.failAfter) throw new Error(fixture.error); + return { + schema: SWEEP_SCHEMA, + fetchedAt: new Date().toISOString(), + org: fixture.profile.org, + range, + ...fixture.sweep, + sprintRisk: null, + }; +} + /** One line per sweep: "[sweep] auto → incremental · 1.2 s · 3 requests · 0 retries". */ function logSweep(requested: string, s: SweepStats | null): void { if (!s) return; diff --git a/desktop/src/main/main.ts b/desktop/src/main/main.ts index c153af5..340c0a0 100644 --- a/desktop/src/main/main.ts +++ b/desktop/src/main/main.ts @@ -9,8 +9,10 @@ */ import { app, BrowserWindow, ipcMain } from 'electron'; import { autoUpdater } from 'electron-updater'; +import { readFileSync } from 'fs'; import * as path from 'path'; import { ConfigService } from './core/config.service'; +import { Fixture, loadFixture } from './core/fixture'; import { GithubService } from './core/github.service'; import { SnapshotStore } from './core/snapshot.store'; import { TokenStore } from './core/token.store'; @@ -143,6 +145,18 @@ function setupAutoUpdate(): void { setInterval(() => void check(), UPDATE_CHECK_INTERVAL_MS); } +/** + * PRSWEEP_FIXTURE=: canned sweeps from e2e/fixtures for reviewing the UI + * in every state (see core/fixture.ts). Unpackaged builds only, so an installed + * app can't be pointed at fake data. + */ +function readFixture(): Fixture | null { + const name = process.env.PRSWEEP_FIXTURE; + if (!name || app.isPackaged) return null; + const dir = path.join(app.getAppPath(), 'e2e', 'fixtures'); + return loadFixture(name, (n) => JSON.parse(readFileSync(path.join(dir, `${n}.json`), 'utf8')), Date.now()); +} + app.whenReady().then(async () => { // A losing second instance is already quitting — don't flash a window/tray // in the moment before the quit lands. @@ -156,6 +170,7 @@ app.whenReady().then(async () => { tokens, github: new GithubService(() => tokens.get()), snapshots: new SnapshotStore(path.join(userDataDir, 'snapshot.json')), + fixture: readFixture(), }; registerIpc(services); @@ -173,6 +188,10 @@ app.whenReady().then(async () => { await createWindow(); setupAutoUpdate(); + // A fixture can play update states into the header, as the updater would. + for (const { afterMs, state } of services.fixture?.update ?? []) { + setTimeout(() => win?.webContents.send('update:state', state), afterMs); + } app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); From 627aee75634072e390ab4a9a2ee517d766cc5839 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Tue, 29 Sep 2026 12:58:39 -0500 Subject: [PATCH 04/30] feat(ui): Quorum tokens, bundled fonts and icons, and AA and asset checks in CI --- .github/workflows/ci.yml | 3 + desktop/package.json | 2 +- desktop/renderer/angular.json | 9 +- .../renderer/licenses/ibm-plex-mono-OFL.txt | 93 +++++++++++++++ desktop/renderer/licenses/lucide.txt | 15 +++ desktop/renderer/licenses/manrope-OFL.txt | 93 +++++++++++++++ desktop/renderer/package-lock.json | 20 ++++ desktop/renderer/package.json | 2 + desktop/renderer/scripts/check-assets.mjs | 61 ++++++++++ desktop/renderer/scripts/check-contrast.mjs | 108 ++++++++++++++++++ desktop/renderer/scripts/checks.test.mjs | 58 ++++++++++ desktop/renderer/src/app/ui/icon.component.ts | 67 +++++++++++ desktop/renderer/src/app/ui/icons.ts | 41 +++++++ desktop/renderer/src/styles.css | 14 ++- desktop/renderer/src/styles/quorum/base.css | 15 +++ desktop/renderer/src/styles/quorum/colors.css | 69 +++++++++++ desktop/renderer/src/styles/quorum/fonts.css | 13 +++ desktop/renderer/src/styles/quorum/layers.css | 2 + desktop/renderer/src/styles/quorum/motion.css | 22 ++++ desktop/renderer/src/styles/quorum/shape.css | 21 ++++ .../renderer/src/styles/quorum/spacing.css | 11 ++ .../renderer/src/styles/quorum/typography.css | 23 ++++ package.json | 1 + 23 files changed, 760 insertions(+), 3 deletions(-) create mode 100644 desktop/renderer/licenses/ibm-plex-mono-OFL.txt create mode 100644 desktop/renderer/licenses/lucide.txt create mode 100644 desktop/renderer/licenses/manrope-OFL.txt create mode 100644 desktop/renderer/scripts/check-assets.mjs create mode 100644 desktop/renderer/scripts/check-contrast.mjs create mode 100644 desktop/renderer/scripts/checks.test.mjs create mode 100644 desktop/renderer/src/app/ui/icon.component.ts create mode 100644 desktop/renderer/src/app/ui/icons.ts create mode 100644 desktop/renderer/src/styles/quorum/base.css create mode 100644 desktop/renderer/src/styles/quorum/colors.css create mode 100644 desktop/renderer/src/styles/quorum/fonts.css create mode 100644 desktop/renderer/src/styles/quorum/layers.css create mode 100644 desktop/renderer/src/styles/quorum/motion.css create mode 100644 desktop/renderer/src/styles/quorum/shape.css create mode 100644 desktop/renderer/src/styles/quorum/spacing.css create mode 100644 desktop/renderer/src/styles/quorum/typography.css diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5cb4bb..4b9a1b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,3 +23,6 @@ jobs: run: npm run build - name: Test run: npm test + # WCAG AA on the Quorum tokens, and no remote assets or stray fonts in the renderer. + - name: Design checks + run: npm run check diff --git a/desktop/package.json b/desktop/package.json index 7814a68..defa3ba 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -7,7 +7,7 @@ "main": "dist/main/main/main.js", "scripts": { "build:main": "tsc -p tsconfig.json", - "test": "node src/main/core/oauth.service.test.mjs && node src/main/core/config.service.test.mjs && node src/main/core/github.service.test.mjs && node src/main/core/snapshot.store.test.mjs && node src/main/core/attention.test.mjs && node src/main/core/tray.text.test.mjs && node src/main/core/fixture.test.mjs", + "test": "node src/main/core/oauth.service.test.mjs && node src/main/core/config.service.test.mjs && node src/main/core/github.service.test.mjs && node src/main/core/snapshot.store.test.mjs && node src/main/core/attention.test.mjs && node src/main/core/tray.text.test.mjs && node src/main/core/fixture.test.mjs && node renderer/scripts/checks.test.mjs", "start": "electron .", "package": "electron-builder", "package:win": "electron-builder --win", diff --git a/desktop/renderer/angular.json b/desktop/renderer/angular.json index d741c97..09a336d 100644 --- a/desktop/renderer/angular.json +++ b/desktop/renderer/angular.json @@ -34,7 +34,14 @@ "styles": [ "src/styles.css" ], - "scripts": [] + "scripts": [], + "assets": [ + { + "glob": "*.txt", + "input": "licenses", + "output": "licenses" + } + ] }, "configurations": { "production": { diff --git a/desktop/renderer/licenses/ibm-plex-mono-OFL.txt b/desktop/renderer/licenses/ibm-plex-mono-OFL.txt new file mode 100644 index 0000000..83eac82 --- /dev/null +++ b/desktop/renderer/licenses/ibm-plex-mono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ThinItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ExtraLight.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ExtraLightItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Light.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-LightItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Regular.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Italic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Medium.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-MediumItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-SemiBold.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-SemiBoldItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Bold.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-BoldItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/desktop/renderer/licenses/lucide.txt b/desktop/renderer/licenses/lucide.txt new file mode 100644 index 0000000..325e8ff --- /dev/null +++ b/desktop/renderer/licenses/lucide.txt @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as part of Feather (MIT). All other copyright (c) for Lucide are held by Lucide Contributors 2022. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/desktop/renderer/licenses/manrope-OFL.txt b/desktop/renderer/licenses/manrope-OFL.txt new file mode 100644 index 0000000..462280f --- /dev/null +++ b/desktop/renderer/licenses/manrope-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2019 The Manrope Project Authors (https://github.com/sharanda/manrope) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/desktop/renderer/package-lock.json b/desktop/renderer/package-lock.json index c55e5c3..99704ad 100644 --- a/desktop/renderer/package-lock.json +++ b/desktop/renderer/package-lock.json @@ -16,6 +16,8 @@ "@angular/platform-browser": "^18.2.0", "@angular/platform-browser-dynamic": "^18.2.0", "@angular/router": "^18.2.0", + "@fontsource/ibm-plex-mono": "^5.3.0", + "@fontsource/manrope": "^5.3.0", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.14.10" @@ -2870,6 +2872,24 @@ "node": ">=18" } }, + "node_modules/@fontsource/ibm-plex-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.3.0.tgz", + "integrity": "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/manrope": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/manrope/-/manrope-5.3.0.tgz", + "integrity": "sha512-obJ1Dv3+uCA6HlHgW8u4BGYxJR9In2HW7gjJhlflEvkrj1X1iSEwu0fToL+JYGC/FEKFfIz1sBuPduvcL2gIAA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@inquirer/checkbox": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-2.5.0.tgz", diff --git a/desktop/renderer/package.json b/desktop/renderer/package.json index 6d22d29..c362029 100644 --- a/desktop/renderer/package.json +++ b/desktop/renderer/package.json @@ -17,6 +17,8 @@ "@angular/platform-browser": "^18.2.0", "@angular/platform-browser-dynamic": "^18.2.0", "@angular/router": "^18.2.0", + "@fontsource/ibm-plex-mono": "^5.3.0", + "@fontsource/manrope": "^5.3.0", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.14.10" diff --git a/desktop/renderer/scripts/check-assets.mjs b/desktop/renderer/scripts/check-assets.mjs new file mode 100644 index 0000000..7beca2b --- /dev/null +++ b/desktop/renderer/scripts/check-assets.mjs @@ -0,0 +1,61 @@ +/** + * Keeps the renderer on Quorum and off the network: + * - no remote URL anywhere in its source except the GitHub pages the app + * opens in the browser (and the SVG namespace), so it never loads fonts, + * icons or scripts from a CDN: the "only GitHub" promise; + * - fonts only through the Quorum font tokens (Manrope, IBM Plex Mono); + * - with --colors, no raw colors outside the token files (styles/quorum/). + * + * Run: node renderer/scripts/check-assets.mjs [--colors] [dir] (from desktop/) + */ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ALLOWED_URL = /^(https:\/\/github\.com\/|http:\/\/www\.w3\.org\/2000\/svg$)/; +const TOKENS_DIR = `styles${sep}quorum${sep}`; + +function files(dir) { + return readdirSync(dir).flatMap((name) => { + const path = join(dir, name); + if (statSync(path).isDirectory()) return files(path); + return /\.(css|ts|html)$/.test(name) ? [path] : []; + }); +} + +/** "app/x.css:3: remote URL https://…" for every violation under `dir`; [] when clean. */ +export function checkAssets(dir, { colors }) { + const failures = []; + for (const path of files(dir)) { + const rel = relative(dir, path); + const tokens = rel.startsWith(TOKENS_DIR); + const css = path.endsWith('.css'); + readFileSync(path, 'utf8') + .split('\n') + .forEach((line, i) => { + const at = `${rel.split(sep).join('/')}:${i + 1}`; + for (const [url] of line.matchAll(/\bhttps?:\/\/[^\s'"`)]+/g)) { + if (css || !ALLOWED_URL.test(url)) failures.push(`${at}: remote URL ${url}`); + } + if (tokens) return; + for (const m of line.matchAll(/\bfont(?:-family)?\s*:\s*([^;}]+)/g)) { + if (!/var\(--(font|text)-|^\s*inherit/.test(m[1])) failures.push(`${at}: font "${m[1].trim()}" — use var(--font-sans), var(--font-mono) or a --text-* token`); + } + if (colors && css) { + for (const [c] of line.matchAll(/#[0-9a-fA-F]{3,8}\b|\b(?:rgba?|hsla?)\([^)]*\)/g)) { + failures.push(`${at}: raw color ${c} — use a Quorum color token`); + } + } + }); + } + return failures; +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + const args = process.argv.slice(2); + const dir = args.find((a) => !a.startsWith('--')) ?? fileURLToPath(new URL('../src', import.meta.url)); + const failures = checkAssets(dir, { colors: args.includes('--colors') }); + for (const f of failures) console.error(f); + if (failures.length) process.exit(1); + console.log(`assets: no remote URLs or stray fonts${args.includes('--colors') ? ', no raw colors' : ''}`); +} diff --git a/desktop/renderer/scripts/check-contrast.mjs b/desktop/renderer/scripts/check-contrast.mjs new file mode 100644 index 0000000..3a83d9a --- /dev/null +++ b/desktop/renderer/scripts/check-contrast.mjs @@ -0,0 +1,108 @@ +/** + * WCAG AA for the Quorum tokens, in both themes: every text color on every + * surface the UI puts it on reaches 4.5:1, and focus rings and status dots + * reach 3:1. Translucent tints are blended over the surface they sit on. + * When the UI puts a new text or status color on a surface, add the pair here. + * + * Run: node renderer/scripts/check-contrast.mjs (from desktop/) + */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const SURFACES = ['bg-0', 'bg-1', 'bg-2', 'bg-3', 'bg-4']; +const STATES = ['healthy', 'warning', 'critical', 'info', 'running', 'unknown']; + +/** [foreground, background, minimum ratio]. A background "x over y" is tint x blended onto y. */ +const PAIRS = [ + ...['text-1', 'text-2', 'text-3'].flatMap((t) => SURFACES.map((s) => [t, s, 4.5])), + ...['bg-0', 'bg-2', 'bg-3'].map((s) => ['accent-text', s, 4.5]), + ['text-inverse', 'accent', 4.5], // primary button label + ...STATES.flatMap((st) => [ + [`${st}-text`, 'bg-2', 4.5], + [`${st}-text`, 'bg-3', 4.5], + [`${st}-text`, `${st}-subtle over bg-2`, 4.5], // status badge label on its tint + ]), + // Banners sit on the page; the app raises errors (critical) and updates (info). + ...['critical', 'info'].map((st) => [`${st}-text`, `${st}-subtle over bg-0`, 4.5]), + ['accent', 'bg-0', 3], // focus ring + ['accent', 'bg-2', 3], + ...['healthy', 'warning', 'critical', 'running'].map((st) => [st, 'bg-2', 3]), // status dots +]; + +function declarations(text) { + const out = {}; + for (const m of text.replace(/\/\*[\s\S]*?\*\//g, '').matchAll(/--([\w-]+)\s*:\s*([^;]+);/g)) out[m[1]] = m[2].trim(); + return out; +} + +/** Dark is :root; light overrides it. */ +export function parseThemes(css) { + const dark = /:root,\s*\[data-theme="dark"\]\s*\{([\s\S]*?)\n\}/.exec(css)?.[1] ?? ''; + const light = /\[data-theme="light"\]\s*\{([\s\S]*?)\n\}/.exec(css)?.[1] ?? ''; + const d = declarations(dark); + return { dark: d, light: { ...d, ...declarations(light) } }; +} + +function resolve(theme, name) { + let value = theme[name]; + for (let i = 0; value && i < 10; i++) { + const m = /^var\(--([\w-]+)\)$/.exec(value); + if (!m) break; + value = theme[m[1]]; + } + if (!value) throw new Error(`--${name} is not defined`); + return value; +} + +function rgba(value) { + const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(value); + if (hex) { + const h = hex[1].length === 3 ? [...hex[1]].map((c) => c + c).join('') : hex[1]; + return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)).concat(1); + } + const fn = /^rgba?\(([^)]+)\)$/i.exec(value); + if (fn) { + const [r, g, b, a = '1'] = fn[1].split(',').map((x) => x.trim()); + return [Number(r), Number(g), Number(b), Number(a)]; + } + throw new Error(`"${value}" is not a color`); +} + +const over = ([r, g, b, a], [R, G, B]) => [r * a + R * (1 - a), g * a + G * (1 - a), b * a + B * (1 - a), 1]; + +function color(theme, spec) { + const [tint, base] = spec.split(' over '); + const c = rgba(resolve(theme, tint)); + return base ? over(c, color(theme, base)) : c; +} + +function luminance([r, g, b]) { + const f = (c) => ((c /= 255) <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); + return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); +} + +export function ratio(a, b) { + const [x, y] = [luminance(a), luminance(b)].sort((m, n) => n - m); + return (x + 0.05) / (y + 0.05); +} + +/** "light: text-3 on bg-4 is 4.21:1, needs 4.5" for every failing pair; [] when all pass. */ +export function checkContrast(css) { + const failures = []; + for (const [name, theme] of Object.entries(parseThemes(css))) { + for (const [fg, bg, min] of PAIRS) { + const background = color(theme, bg); + const r = ratio(over(color(theme, fg), background), background); + if (r < min) failures.push(`${name}: ${fg} on ${bg} is ${r.toFixed(2)}:1, needs ${min}`); + } + } + return failures; +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + const file = process.argv[2] ?? fileURLToPath(new URL('../src/styles/quorum/colors.css', import.meta.url)); + const failures = checkContrast(readFileSync(file, 'utf8')); + for (const f of failures) console.error(f); + if (failures.length) process.exit(1); + console.log(`contrast: ${PAIRS.length} pairs pass AA in both themes`); +} diff --git a/desktop/renderer/scripts/checks.test.mjs b/desktop/renderer/scripts/checks.test.mjs new file mode 100644 index 0000000..14bccd9 --- /dev/null +++ b/desktop/renderer/scripts/checks.test.mjs @@ -0,0 +1,58 @@ +/** + * The design checks, and their positive controls: each check has to pass on + * the real sources and fail on a planted violation, or it proves nothing. + * Run: node renderer/scripts/checks.test.mjs (from desktop/) + */ +import assert from 'node:assert'; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { checkAssets } from './check-assets.mjs'; +import { checkContrast } from './check-contrast.mjs'; + +const tokens = fileURLToPath(new URL('../src/styles/quorum/colors.css', import.meta.url)); +const src = fileURLToPath(new URL('../src', import.meta.url)); + +// --- contrast: the real tokens pass --- +{ + const failures = checkContrast(readFileSync(tokens, 'utf8')); + assert.deepEqual(failures, [], `real tokens: ${failures.join('; ')}`); +} + +// --- contrast, positive control: a muted text color that fails AA is caught, in the right theme --- +{ + const css = readFileSync(tokens, 'utf8').replace('--text-2:#525C6B;', '--text-2:#9AA1AC;'); + const failures = checkContrast(css); + assert.ok(failures.some((f) => f.startsWith('light: text-2 on bg-2')), failures.join('; ')); + assert.ok(!failures.some((f) => f.startsWith('dark:')), 'dark untouched'); +} + +// --- assets: the real sources pass (raw colors are checked separately, once the old styles are gone) --- +{ + const failures = checkAssets(src, { colors: false }); + assert.deepEqual(failures, [], failures.join('\n')); +} + +// --- assets, positive controls --- +{ + const dir = mkdtempSync(join(tmpdir(), 'prsweep-checks-')); + mkdirSync(join(dir, 'app')); + mkdirSync(join(dir, 'styles', 'quorum'), { recursive: true }); + writeFileSync(join(dir, 'app', 'ok.ts'), `const url = 'https://github.com/settings/tokens';\nconst ns = 'http://www.w3.org/2000/svg';\n`); + writeFileSync(join(dir, 'styles', 'quorum', 'tokens.css'), `:root{ --x:#FFFFFF; --font-sans:"Manrope",sans-serif; }\n`); + assert.deepEqual(checkAssets(dir, { colors: true }), [], 'GitHub links, the SVG namespace and the token files are fine'); + + writeFileSync(join(dir, 'app', 'cdn.css'), `@import url('https://fonts.googleapis.com/css2?family=Inter');\n`); + writeFileSync(join(dir, 'app', 'font.css'), `.x { font-family: Arial, sans-serif; }\n`); + writeFileSync(join(dir, 'app', 'remote.ts'), `const icon = 'https://unpkg.com/lucide@0.460.0/dist/umd/lucide.min.js';\n`); + writeFileSync(join(dir, 'app', 'hex.css'), `.y { color: #ff0000; }\n`); + const found = checkAssets(dir, { colors: true }).join('\n'); + assert.match(found, /cdn\.css:1: remote URL/); + assert.match(found, /font\.css:1: font/); + assert.match(found, /remote\.ts:1: remote URL/); + assert.match(found, /hex\.css:1: raw color/); + assert.doesNotMatch(checkAssets(dir, { colors: false }).join('\n'), /raw color/, 'the colors rule is opt-in until v0.12 step 7'); +} + +console.log('checks: contrast and assets pass on the real sources and catch planted violations'); diff --git a/desktop/renderer/src/app/ui/icon.component.ts b/desktop/renderer/src/app/ui/icon.component.ts new file mode 100644 index 0000000..c675c20 --- /dev/null +++ b/desktop/renderer/src/app/ui/icon.component.ts @@ -0,0 +1,67 @@ +import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; +import { IconName, ICONS } from './icons'; + +/** + * A Lucide icon drawn from vendored element data (see icons.ts). Decorative by + * default: the button or text next to it carries the meaning, so it's hidden + * from assistive tech. Quorum draws icons at 14px with a 1.75px stroke. + */ +@Component({ + selector: 'q-icon', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'q-icon', 'aria-hidden': 'true' }, + template: ` + + @for (s of shapes(); track $index) { + @switch (s[0]) { + @case ('path') { + + } + @case ('circle') { + + } + @case ('line') { + + } + @case ('polyline') { + + } + @case ('polygon') { + + } + @case ('rect') { + + } + @case ('ellipse') { + + } + } + } + + `, +}) +export class IconComponent { + readonly name = input.required(); + readonly size = input(14); + readonly stroke = input(1.75); + readonly shapes = computed(() => ICONS[this.name()]); +} diff --git a/desktop/renderer/src/app/ui/icons.ts b/desktop/renderer/src/app/ui/icons.ts new file mode 100644 index 0000000..ef6a546 --- /dev/null +++ b/desktop/renderer/src/app/ui/icons.ts @@ -0,0 +1,41 @@ +/** + * Lucide icons (lucide-static 0.460.0, ISC License, licenses/lucide.txt), vendored as element data so + * the app loads nothing from a CDN. Generated from lucide-static/icons/.svg; only the icons + * the screens use are here. To add one, copy its elements from that file in the same shape. + */ +export type IconShape = [tag: 'path' | 'circle' | 'line' | 'polyline' | 'polygon' | 'rect' | 'ellipse', attrs: Record]; + +export const ICONS = { + 'refresh-cw': [["path",{"d":"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"}],["path",{"d":"M21 3v5h-5"}],["path",{"d":"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"}],["path",{"d":"M8 16H3v5"}]], + 'sun': [["circle",{"cx":"12","cy":"12","r":"4"}],["path",{"d":"M12 2v2"}],["path",{"d":"M12 20v2"}],["path",{"d":"m4.93 4.93 1.41 1.41"}],["path",{"d":"m17.66 17.66 1.41 1.41"}],["path",{"d":"M2 12h2"}],["path",{"d":"M20 12h2"}],["path",{"d":"m6.34 17.66-1.41 1.41"}],["path",{"d":"m19.07 4.93-1.41 1.41"}]], + 'moon': [["path",{"d":"M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"}]], + 'settings': [["path",{"d":"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"}],["circle",{"cx":"12","cy":"12","r":"3"}]], + 'layout-list': [["rect",{"width":"7","height":"7","x":"3","y":"3","rx":"1"}],["rect",{"width":"7","height":"7","x":"3","y":"14","rx":"1"}],["path",{"d":"M14 4h7"}],["path",{"d":"M14 9h7"}],["path",{"d":"M14 15h7"}],["path",{"d":"M14 20h7"}]], + 'check': [["path",{"d":"M20 6 9 17l-5-5"}]], + 'x': [["path",{"d":"M18 6 6 18"}],["path",{"d":"m6 6 12 12"}]], + 'clock': [["circle",{"cx":"12","cy":"12","r":"10"}],["polyline",{"points":"12 6 12 12 16 14"}]], + 'loader-circle': [["path",{"d":"M21 12a9 9 0 1 1-6.219-8.56"}]], + 'triangle-alert': [["path",{"d":"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"}],["path",{"d":"M12 9v4"}],["path",{"d":"M12 17h.01"}]], + 'circle-alert': [["circle",{"cx":"12","cy":"12","r":"10"}],["line",{"x1":"12","x2":"12","y1":"8","y2":"12"}],["line",{"x1":"12","x2":"12.01","y1":"16","y2":"16"}]], + 'search': [["circle",{"cx":"11","cy":"11","r":"8"}],["path",{"d":"m21 21-4.3-4.3"}]], + 'external-link': [["path",{"d":"M15 3h6v6"}],["path",{"d":"M10 14 21 3"}],["path",{"d":"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"}]], + 'bell-off': [["path",{"d":"M8.7 3A6 6 0 0 1 18 8a21.3 21.3 0 0 0 .6 5"}],["path",{"d":"M17 17H3s3-2 3-9a4.67 4.67 0 0 1 .3-1.7"}],["path",{"d":"M10.3 21a1.94 1.94 0 0 0 3.4 0"}],["path",{"d":"m2 2 20 20"}]], + 'eye': [["path",{"d":"M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"}],["circle",{"cx":"12","cy":"12","r":"3"}]], + 'eye-off': [["path",{"d":"M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"}],["path",{"d":"M14.084 14.158a3 3 0 0 1-4.242-4.242"}],["path",{"d":"M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"}],["path",{"d":"m2 2 20 20"}]], + 'chevron-left': [["path",{"d":"m15 18-6-6 6-6"}]], + 'chevron-right': [["path",{"d":"m9 18 6-6-6-6"}]], + 'calendar': [["path",{"d":"M8 2v4"}],["path",{"d":"M16 2v4"}],["rect",{"width":"18","height":"18","x":"3","y":"4","rx":"2"}],["path",{"d":"M3 10h18"}]], + 'info': [["circle",{"cx":"12","cy":"12","r":"10"}],["path",{"d":"M12 16v-4"}],["path",{"d":"M12 8h.01"}]], + 'git-pull-request': [["circle",{"cx":"18","cy":"18","r":"3"}],["circle",{"cx":"6","cy":"6","r":"3"}],["path",{"d":"M13 6h3a2 2 0 0 1 2 2v7"}],["line",{"x1":"6","x2":"6","y1":"9","y2":"21"}]], + 'plus': [["path",{"d":"M5 12h14"}],["path",{"d":"M12 5v14"}]], + 'trash-2': [["path",{"d":"M3 6h18"}],["path",{"d":"M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"}],["path",{"d":"M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"}],["line",{"x1":"10","x2":"10","y1":"11","y2":"17"}],["line",{"x1":"14","x2":"14","y1":"11","y2":"17"}]], + 'download': [["path",{"d":"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"}],["polyline",{"points":"7 10 12 15 17 10"}],["line",{"x1":"12","x2":"12","y1":"15","y2":"3"}]], + 'upload': [["path",{"d":"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"}],["polyline",{"points":"17 8 12 3 7 8"}],["line",{"x1":"12","x2":"12","y1":"3","y2":"15"}]], + 'log-in': [["path",{"d":"M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"}],["polyline",{"points":"10 17 15 12 10 7"}],["line",{"x1":"15","x2":"3","y1":"12","y2":"12"}]], + 'key-round': [["path",{"d":"M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"}],["circle",{"cx":"16.5","cy":"7.5","r":".5","fill":"currentColor"}]], + 'circle-check': [["circle",{"cx":"12","cy":"12","r":"10"}],["path",{"d":"m9 12 2 2 4-4"}]], + 'circle-x': [["circle",{"cx":"12","cy":"12","r":"10"}],["path",{"d":"m15 9-6 6"}],["path",{"d":"m9 9 6 6"}]], + 'git-merge': [["circle",{"cx":"18","cy":"18","r":"3"}],["circle",{"cx":"6","cy":"6","r":"3"}],["path",{"d":"M6 21V9a9 9 0 0 0 9 9"}]], +} satisfies Record; + +export type IconName = keyof typeof ICONS; diff --git a/desktop/renderer/src/styles.css b/desktop/renderer/src/styles.css index 09c9a6a..615c153 100644 --- a/desktop/renderer/src/styles.css +++ b/desktop/renderer/src/styles.css @@ -1,3 +1,15 @@ +/* Quorum Design System tokens (see styles/quorum/). Imported first: until the v0.12 restyle + replaces the rules below, the old variables that share a name (--space-7, --space-8, + --font-sans) still win, so the current look holds screen by screen. */ +@import './styles/quorum/fonts.css'; +@import './styles/quorum/colors.css'; +@import './styles/quorum/typography.css'; +@import './styles/quorum/spacing.css'; +@import './styles/quorum/shape.css'; +@import './styles/quorum/motion.css'; +@import './styles/quorum/layers.css'; +@import './styles/quorum/base.css'; + /* ===== pr-sweep design system ===== Utilitarian catalog-density look (think industrial-supply websites): Arial, zero radius, no shadows, dense bordered tables with row striping. Navy/gold @@ -358,7 +370,7 @@ tr.snoozed td:not(.next), tr.quiet td:not(.next) { opacity: 0.55; } .modal .alt a { color: var(--color-link); } .device-code { text-align: center; margin-top: var(--space-5); } .device-code .code { - font-family: Consolas, 'Courier New', monospace; + font-family: var(--font-mono); font-size: var(--fs-h1); font-weight: var(--fw-bold); letter-spacing: 4px; diff --git a/desktop/renderer/src/styles/quorum/base.css b/desktop/renderer/src/styles/quorum/base.css new file mode 100644 index 0000000..1cdd638 --- /dev/null +++ b/desktop/renderer/src/styles/quorum/base.css @@ -0,0 +1,15 @@ +/* Quorum Design System: tokens/base.css (claude.ai/design, "Quorum Design System"). Copied as-is. + Minimal base: body reset, link colors, scrollbars, selection, focus. */ +html,body{margin:0;padding:0} +body{background:var(--surface-app);color:var(--text-body);font:var(--text-body);-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility} +*,*::before,*::after{box-sizing:border-box} +a{color:var(--text-link);text-decoration:none} +a:hover{color:var(--accent-hover);text-decoration:underline;text-underline-offset:2px} +code,kbd,pre,samp{font-family:var(--font-mono)} +::selection{background:var(--accent-subtle);color:var(--text-1)} +:focus-visible{outline:none;box-shadow:var(--focus-ring)} +*{scrollbar-width:thin;scrollbar-color:var(--border-3) transparent} +*::-webkit-scrollbar{width:8px;height:8px} +*::-webkit-scrollbar-thumb{background:var(--border-3);border-radius:4px} +*::-webkit-scrollbar-track{background:transparent} +.q-grid-paper{background-image:linear-gradient(var(--grid-line) 1px,transparent 1px),linear-gradient(90deg,var(--grid-line) 1px,transparent 1px);background-size:24px 24px} diff --git a/desktop/renderer/src/styles/quorum/colors.css b/desktop/renderer/src/styles/quorum/colors.css new file mode 100644 index 0000000..053b214 --- /dev/null +++ b/desktop/renderer/src/styles/quorum/colors.css @@ -0,0 +1,69 @@ +/* Quorum Design System: tokens/colors.css (claude.ai/design, "Quorum Design System"). + Copied as-is so a later Quorum update can be diffed in. Local changes are marked AA. + Dark is the first-class theme; light is a full re-tuning, not an inversion. */ +:root, [data-theme="dark"]{ + color-scheme:dark; + /* Neutral foundation */ + --bg-0:#0A0C10; /* app background, near-black, cool */ + --bg-1:#0F1218; /* panel */ + --bg-2:#151920; /* elevated surface, card */ + --bg-3:#1B2029; /* overlay, popover, hover surface */ + --bg-4:#222834; /* pressed / selected surface */ + --bg-inset:#07090C; /* code blocks, log viewer, wells */ + --border-1:#1E242E; /* hairline */ + --border-2:#2A3240; /* standard */ + --border-3:#3A4453; /* strong / focus rest */ + --text-1:#E8ECF1; /* primary */ + --text-2:#98A2B3; /* secondary */ + --text-3:#8590A0; /* tertiary, placeholders, micro labels. AA: was #5F6B7A (2.7–3.6:1) */ + --text-4:#3E4754; /* disabled */ + --text-inverse:#0A0C10; + /* Accent (interactive) */ + --accent:#2DD4BF; + --accent-hover:#5EEAD4; + --accent-active:#14B8A6; + --accent-text:#7FF0E0; + --accent-subtle:rgba(45,212,191,.10); + --accent-border:rgba(45,212,191,.35); + --accent-glow:0 0 0 1px rgba(45,212,191,.35),0 0 18px rgba(45,212,191,.18); + /* Semantic states */ + --healthy:#3DD68C; --healthy-text:#7BE8B3; --healthy-subtle:rgba(61,214,140,.10); --healthy-border:rgba(61,214,140,.35); + --warning:#F2B441; --warning-text:#F8CE7A; --warning-subtle:rgba(242,180,65,.12); --warning-border:rgba(242,180,65,.40); + --critical:#F0555D; --critical-text:#FF8A90; --critical-subtle:rgba(240,85,93,.12); --critical-border:rgba(240,85,93,.45); + --info:#5B9CFF; --info-text:#93BDFF; --info-subtle:rgba(91,156,255,.12); --info-border:rgba(91,156,255,.40); + --unknown:#5F6B7A; --unknown-text:#98A2B3; --unknown-subtle:rgba(95,107,122,.14); --unknown-border:rgba(95,107,122,.45); + --paused:#A6ADBB; --paused-text:#C4CAD4; --paused-subtle:rgba(166,173,187,.10); --paused-border:rgba(166,173,187,.35); + --running:#38BDF8; --running-text:#7DD3FC; --running-subtle:rgba(56,189,248,.12); --running-border:rgba(56,189,248,.40); + --queued:#B48EFF; --queued-text:#CDB4FF; --queued-subtle:rgba(180,142,255,.12); --queued-border:rgba(180,142,255,.40); + --maintenance:#E08E4A; --maintenance-text:#F0B07F; --maintenance-subtle:rgba(224,142,74,.12); --maintenance-border:rgba(224,142,74,.40); + /* Semantic aliases */ + --surface-app:var(--bg-0); --surface-panel:var(--bg-1); --surface-card:var(--bg-2); --surface-overlay:var(--bg-3); --surface-selected:var(--bg-4); --surface-inset:var(--bg-inset); + --text-body:var(--text-1); --text-muted:var(--text-2); --text-faint:var(--text-3); --text-link:var(--accent-text); + --border-hairline:var(--border-1); --border-default:var(--border-2); --border-strong:var(--border-3); + --focus-ring:0 0 0 2px var(--bg-0),0 0 0 4px var(--accent); + --scrim:rgba(4,6,9,.65); + --grid-line:rgba(152,162,179,.06); /* graph-paper motif */ + --grid-line-strong:rgba(152,162,179,.12); + --highlight-inset:inset 0 1px 0 rgba(255,255,255,.04); +} +[data-theme="light"]{ + color-scheme:light; + --bg-0:#F3F4F6; --bg-1:#FAFAFB; --bg-2:#FFFFFF; --bg-3:#F6F7F9; --bg-4:#EDEFF3; --bg-inset:#F7F8FA; + --border-1:#E6E8EC; --border-2:#D7DBE2; --border-3:#BCC3CE; + --text-1:#171B22; --text-2:#525C6B; --text-3:#636D7C; --text-4:#B3BAC5; --text-inverse:#FFFFFF; /* AA: --text-3 was #7D8796 (3.2–3.6:1) */ + --accent:#0E7C73; --accent-hover:#0F766E; --accent-active:#115E59; --accent-text:#0B7A70; /* AA: --accent was #0D9488 (a white button label reached 3.74:1) */ + --accent-subtle:rgba(13,148,136,.10); --accent-border:rgba(13,148,136,.35); --accent-glow:0 0 0 1px rgba(13,148,136,.3),0 0 14px rgba(13,148,136,.12); + --healthy:#1E9E5E; --healthy-text:#177A49; --healthy-subtle:rgba(30,158,94,.10); --healthy-border:rgba(30,158,94,.35); + --warning:#B67D0F; /* AA: was #C98A12 (2.95:1 as a status dot on a card) */ --warning-text:#8F6100; --warning-subtle:rgba(201,138,18,.12); --warning-border:rgba(201,138,18,.40); + --critical:#D7343E; --critical-text:#B0232C; --critical-subtle:rgba(215,52,62,.10); --critical-border:rgba(215,52,62,.40); + --info:#2E6FE0; --info-text:#2358B8; --info-subtle:rgba(46,111,224,.10); --info-border:rgba(46,111,224,.35); + --unknown:#8A94A3; --unknown-text:#5C6675; --unknown-subtle:rgba(138,148,163,.14); --unknown-border:rgba(138,148,163,.45); + --paused:#6B7484; --paused-text:#4F5865; --paused-subtle:rgba(107,116,132,.10); --paused-border:rgba(107,116,132,.35); + --running:#0A8FCB; --running-text:#0A6F9D; --running-subtle:rgba(10,143,203,.10); --running-border:rgba(10,143,203,.35); + --queued:#7C5CD6; --queued-text:#5E3FB8; --queued-subtle:rgba(124,92,214,.10); --queued-border:rgba(124,92,214,.35); + --maintenance:#C4681F; --maintenance-text:#94500F; --maintenance-subtle:rgba(196,104,31,.10); --maintenance-border:rgba(196,104,31,.35); + --focus-ring:0 0 0 2px #FFFFFF,0 0 0 4px var(--accent); + --scrim:rgba(23,27,34,.45); + --grid-line:rgba(23,27,34,.05); --grid-line-strong:rgba(23,27,34,.10); + --highlight-inset:none; +} diff --git a/desktop/renderer/src/styles/quorum/fonts.css b/desktop/renderer/src/styles/quorum/fonts.css new file mode 100644 index 0000000..6eaa868 --- /dev/null +++ b/desktop/renderer/src/styles/quorum/fonts.css @@ -0,0 +1,13 @@ +/* Quorum Design System: tokens/fonts.css, adapted. Quorum loads Manrope and IBM Plex Mono from + Google Fonts; PR Sweep bundles the same families from Fontsource (OFL 1.1, license texts in + licenses/) so the app never loads anything from a CDN. */ +@import '@fontsource/manrope/400.css'; +@import '@fontsource/manrope/500.css'; +@import '@fontsource/manrope/600.css'; +@import '@fontsource/manrope/700.css'; +@import '@fontsource/ibm-plex-mono/400.css'; +@import '@fontsource/ibm-plex-mono/500.css'; +:root{ + --font-sans:"Manrope",ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif; + --font-mono:"IBM Plex Mono",ui-monospace,"SF Mono",Menlo,Consolas,monospace; +} diff --git a/desktop/renderer/src/styles/quorum/layers.css b/desktop/renderer/src/styles/quorum/layers.css new file mode 100644 index 0000000..7cfa9d3 --- /dev/null +++ b/desktop/renderer/src/styles/quorum/layers.css @@ -0,0 +1,2 @@ +/* Quorum Design System: tokens/layers.css (claude.ai/design, "Quorum Design System"). Copied as-is, minus the design tool's @kind metadata comments. */ +:root{ --z-base:0; --z-sticky:50; --z-dropdown:100; --z-drawer:200; --z-modal:300; --z-palette:350; --z-toast:400; --z-tooltip:500; } diff --git a/desktop/renderer/src/styles/quorum/motion.css b/desktop/renderer/src/styles/quorum/motion.css new file mode 100644 index 0000000..ebed551 --- /dev/null +++ b/desktop/renderer/src/styles/quorum/motion.css @@ -0,0 +1,22 @@ +/* Quorum Design System: tokens/motion.css (claude.ai/design, "Quorum Design System"). Copied as-is, minus the design tool's @kind metadata comments. + Motion presets. Monitoring software should feel immediate. */ +:root{ + --dur-instant:80ms; --dur-fast:120ms; --dur-standard:180ms; --dur-panel:240ms; --dur-complex:320ms; + --ease-entrance:cubic-bezier(.2,.8,.2,1); + --ease-exit:cubic-bezier(.4,0,1,1); + --ease-reposition:cubic-bezier(.2,0,0,1); + --ease-data:cubic-bezier(.3,.7,.4,1); + --ease-standard:cubic-bezier(.25,.1,.25,1); + --pulse-duration:2000ms; +} +@media (prefers-reduced-motion:reduce){ + :root{ --dur-instant:0ms; --dur-fast:0ms; --dur-standard:0ms; --dur-panel:0ms; --dur-complex:0ms; --pulse-duration:0ms; } +} +@keyframes q-pulse{0%,100%{box-shadow:0 0 0 0 currentColor;opacity:1}70%{box-shadow:0 0 0 5px transparent;opacity:.85}} +@keyframes q-live{0%,100%{opacity:1}50%{opacity:.35}} +@keyframes q-shimmer{0%{background-position:-200% 0}100%{background-position:200% 0}} +@keyframes q-sweep{0%{transform:translateX(-100%)}100%{transform:translateX(300%)}} +@keyframes q-fade-in{from{opacity:0}to{opacity:1}} +@keyframes q-slide-up{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}} +@keyframes q-slide-in-right{from{opacity:0;transform:translateX(16px)}to{opacity:1;transform:none}} +@keyframes q-log-arrive{from{opacity:0;transform:translateY(-6px);background:var(--accent-subtle)}to{opacity:1;transform:none;background:transparent}} diff --git a/desktop/renderer/src/styles/quorum/shape.css b/desktop/renderer/src/styles/quorum/shape.css new file mode 100644 index 0000000..4c6d942 --- /dev/null +++ b/desktop/renderer/src/styles/quorum/shape.css @@ -0,0 +1,21 @@ +/* Quorum Design System: tokens/shape.css (claude.ai/design, "Quorum Design System"). Copied as-is. + Radius & elevation. Radius stays small: this is an instrument, not a toy. */ +:root{ + --radius-1:2px; /* dense technical elements: log chips, sparkline bars, kbd */ + --radius-2:4px; /* inputs, controls, buttons */ + --radius-3:6px; /* standard components: cards, popovers */ + --radius-4:8px; /* larger surfaces: panels, modals */ + --radius-5:12px; /* rare prominent containers */ + --radius-round:999px; /* status dots only */ + --shadow-1:0 1px 2px rgba(0,0,0,.35); + --shadow-2:0 2px 8px rgba(0,0,0,.40),0 0 0 1px var(--border-1); + --shadow-3:0 8px 24px rgba(0,0,0,.50),0 0 0 1px var(--border-2); + --shadow-4:0 24px 64px rgba(0,0,0,.60),0 0 0 1px var(--border-2); + --border-width:1px; +} +[data-theme="light"]{ + --shadow-1:0 1px 2px rgba(23,27,34,.06); + --shadow-2:0 2px 8px rgba(23,27,34,.08),0 0 0 1px var(--border-1); + --shadow-3:0 8px 24px rgba(23,27,34,.12),0 0 0 1px var(--border-2); + --shadow-4:0 24px 64px rgba(23,27,34,.18),0 0 0 1px var(--border-2); +} diff --git a/desktop/renderer/src/styles/quorum/spacing.css b/desktop/renderer/src/styles/quorum/spacing.css new file mode 100644 index 0000000..8dfbb09 --- /dev/null +++ b/desktop/renderer/src/styles/quorum/spacing.css @@ -0,0 +1,11 @@ +/* Quorum Design System: tokens/spacing.css (claude.ai/design, "Quorum Design System"). Copied as-is. + 8px foundation with 2/4px increments. Density modes scale row/cell metrics, not the base scale. */ +:root{ + --space-0:0; --space-1:2px; --space-2:4px; --space-3:6px; --space-4:8px; --space-5:12px; --space-6:16px; --space-7:20px; --space-8:24px; --space-9:32px; --space-10:40px; --space-11:48px; --space-12:64px; + /* Density: defaults = compact */ + --density-row:32px; --density-cell-y:6px; --density-cell-x:12px; --density-control:28px; --density-gap:12px; --density-panel-pad:16px; --density-font:13px; --density-font-data:12px; + --sidebar-width:216px; --sidebar-collapsed:48px; --topbar-height:44px; --inspector-width:380px; +} +[data-density="comfortable"]{ --density-row:40px; --density-cell-y:10px; --density-cell-x:16px; --density-control:32px; --density-gap:16px; --density-panel-pad:20px; --density-font:14px; --density-font-data:13px; } +[data-density="compact"]{ --density-row:32px; --density-cell-y:6px; --density-cell-x:12px; --density-control:28px; --density-gap:12px; --density-panel-pad:16px; --density-font:13px; --density-font-data:12px; } +[data-density="dense"]{ --density-row:26px; --density-cell-y:3px; --density-cell-x:8px; --density-control:24px; --density-gap:8px; --density-panel-pad:12px; --density-font:12px; --density-font-data:11.5px; } diff --git a/desktop/renderer/src/styles/quorum/typography.css b/desktop/renderer/src/styles/quorum/typography.css new file mode 100644 index 0000000..62b1621 --- /dev/null +++ b/desktop/renderer/src/styles/quorum/typography.css @@ -0,0 +1,23 @@ +/* Quorum Design System: tokens/typography.css (claude.ai/design, "Quorum Design System"). Copied as-is, minus the design tool's @kind metadata comments. + Type scale. Sans for interface, mono for evidence (timestamps, IDs, values, logs). */ +:root{ + --text-display:600 28px/1.15 var(--font-sans); + --text-page-title:600 20px/1.2 var(--font-sans); + --text-section-title:600 15px/1.3 var(--font-sans); + --text-card-title:600 13px/1.3 var(--font-sans); + --text-body:400 13px/1.5 var(--font-sans); + --text-body-strong:600 13px/1.5 var(--font-sans); + --text-compact:400 12px/1.45 var(--font-sans); + --text-label:600 11px/1.2 var(--font-sans); /* uppercase, tracking .06em */ + --text-micro:500 10px/1.2 var(--font-sans); /* uppercase, tracking .08em */ + --text-data:500 12px/1.4 var(--font-mono); /* IDs, timestamps, durations */ + --text-data-value:500 13px/1.3 var(--font-mono); /* numbers in tables */ + --text-metric:500 24px/1.1 var(--font-mono); /* big numbers */ + --text-metric-lg:500 32px/1.05 var(--font-mono); + --text-mono:400 12px/1.5 var(--font-mono); + --text-log:400 12px/1.55 var(--font-mono); + --text-code:400 12.5px/1.6 var(--font-mono); + --tracking-label:.06em; --tracking-micro:.08em; --tracking-tight:-.01em; --tracking-display:-.02em; + --tnum:"tnum" 1,"lnum" 1; + --weight-regular:400; --weight-medium:500; --weight-semibold:600; --weight-bold:700; +} diff --git a/package.json b/package.json index c307271..ef74d95 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "dev:app": "wait-on http://localhost:4301 && cd desktop && npm run build:main && cross-env ELECTRON_RENDERER_URL=http://localhost:4301 npm start", "build": "cd desktop/renderer && npm run build && cd .. && npm run build:main", "test": "cd desktop && npm test", + "check": "node desktop/renderer/scripts/check-contrast.mjs && node desktop/renderer/scripts/check-assets.mjs", "package": "npm run build && cd desktop && npm run package", "package:win": "npm run build && cd desktop && npm run package:win", "package:linux": "npm run build && cd desktop && npm run package:linux" From 4255bea407578c15c200a5110e3ecb154b382494 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Tue, 29 Sep 2026 12:58:52 -0500 Subject: [PATCH 05/30] docs(roadmap): record the v0.12 foundation and its AA fixes --- ROADMAP.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c4694fe..e087820 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -62,8 +62,11 @@ what to do next. come back. The sprint summary moves to v0.13, so there are five minor releases to 1.0: v0.11 Sweep, v0.12 Quorum and sprints, v0.13 Sprint summary, v0.14 Related work, v0.15 Release candidate. -- **Quorum, adapted.** Its tokens are copied as-is, with one local change: `text-3` fails AA in - both themes (3.3–3.6:1), so it becomes `#636D7C` in light and `#8590A0` in dark. Manrope, IBM +- **Quorum, adapted.** Its tokens are copied as-is, with local AA changes: `text-3` fails in both + themes (3.3–3.6:1), so it becomes `#636D7C` in light and `#8590A0` in dark. The contrast check + found two more in the light theme: white button labels on `--accent` `#0D9488` reach only + 3.74:1 (now `#0E7C73`), and `--warning` `#C98A12` as a status dot reaches 2.95:1 (now + `#B67D0F`). Manrope, IBM Plex Mono and the Lucide icons are bundled, never loaded from a CDN, to keep the "only GitHub" promise. Quorum's React components are the reference, rebuilt as a few small Angular components only where a pattern repeats; everything else is plain markup on Quorum tokens. @@ -288,16 +291,17 @@ v0.10.x fills in every row, and the budget holds with the numbers recorded here. Theme: PR Sweep looks and reads like one calm instrument, and knows which sprint it's in. Built on the `release/v0.12` branch. -- [ ] Roadmap, `AGENTS.md` and a draft pull request for the milestone. -- [ ] Fixture mode (`PRSWEEP_FIXTURE=`, unpackaged builds only): a canned sweep run through +- [x] Roadmap, `AGENTS.md` and a draft pull request for the milestone. +- [x] Fixture mode (`PRSWEEP_FIXTURE=`, unpackaged builds only): a canned sweep run through the real attention engine, with no network. Fixtures `busy`, `calm`, `empty`, `error`, `onboarding`, `update` and `loading` use relative times. A loader in `core/` has tests, including one that `busy` triggers all nine attention reasons. -- [ ] The screenshot script shoots every fixture state in both themes, each run in its own +- [x] The screenshot script shoots every fixture state in both themes, each run in its own throwaway `--user-data-dir`. "Before" screenshots of the v0.11 UI go on the pull request. -- [ ] Foundation: Quorum tokens with the AA fix, bundled Manrope and IBM Plex Mono with their OFL +- [x] Foundation: Quorum tokens with the AA fix, bundled Manrope and IBM Plex Mono with their OFL texts, and vendored Lucide icons with the ISC notice. `check-contrast` and `check-assets` - run in CI, each with a positive control. + run in CI, each with a positive control. *The fonts take the renderer build from 329 KB to + 949 KB.* - [ ] Sprints core: `core/sprints.ts` (schedule math, the current sprint, period resolution), `sprints` and `period` on each profile with a migration, and a `period:resolve` IPC call. - [ ] Shell: top bar with the sprint picker, freshness indicator, loading bar, tabs, theme From 68377212a9e0908c02341b561eb8420bca468eef Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Tue, 29 Sep 2026 13:02:33 -0500 Subject: [PATCH 06/30] feat(sprints): computed sprint schedules per profile, resolved in the main process --- AGENTS.md | 3 + ROADMAP.md | 2 +- desktop/e2e/fixtures/busy.json | 3 +- desktop/package.json | 2 +- desktop/renderer/src/app/board.store.ts | 2 + desktop/renderer/src/app/models.ts | 32 +++++ desktop/src/main/core/config.service.test.mjs | 41 +++++++ desktop/src/main/core/config.service.ts | 34 +++++- desktop/src/main/core/fixture.test.mjs | 29 ++++- desktop/src/main/core/fixture.ts | 18 ++- desktop/src/main/core/sprints.test.mjs | 111 ++++++++++++++++++ desktop/src/main/core/sprints.ts | 100 ++++++++++++++++ desktop/src/main/ipc.ts | 2 + desktop/src/preload/preload.ts | 1 + desktop/src/shared/types.ts | 53 +++++++++ 15 files changed, 426 insertions(+), 7 deletions(-) create mode 100644 desktop/src/main/core/sprints.test.mjs create mode 100644 desktop/src/main/core/sprints.ts diff --git a/AGENTS.md b/AGENTS.md index 820bcd9..81141a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,6 +68,9 @@ organization. It ships for Windows and Linux. "needs attention". `prs:fetch` runs it over every open row after every sweep, cached rows included. The Sweep section, the tray line and the sprint summary read its output; the renderer never decides on its own whether a PR needs attention. +- Sprints are computed, never stored: a profile keeps a schedule (`sprints`) and a `period` + (`'current'`, a pinned `{ sprint }` or `'custom'`), and `core/sprints.ts` resolves them against + today through `period:resolve`. The renderer never does sprint date math itself. - Workflow health, not human performance: no per-person counts, leaderboards or review stats. - No AI features before 1.0. - Windows and Linux only. macOS is not planned. diff --git a/ROADMAP.md b/ROADMAP.md index e087820..5e88c20 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -302,7 +302,7 @@ on the `release/v0.12` branch. texts, and vendored Lucide icons with the ISC notice. `check-contrast` and `check-assets` run in CI, each with a positive control. *The fonts take the renderer build from 329 KB to 949 KB.* -- [ ] Sprints core: `core/sprints.ts` (schedule math, the current sprint, period resolution), +- [x] Sprints core: `core/sprints.ts` (schedule math, the current sprint, period resolution), `sprints` and `period` on each profile with a migration, and a `period:resolve` IPC call. - [ ] Shell: top bar with the sprint picker, freshness indicator, loading bar, tabs, theme toggle, update pill and error banner. diff --git a/desktop/e2e/fixtures/busy.json b/desktop/e2e/fixtures/busy.json index 77aa799..206761e 100644 --- a/desktop/e2e/fixtures/busy.json +++ b/desktop/e2e/fixtures/busy.json @@ -4,7 +4,8 @@ "authors": ["dana", "lee", "sam", "priya", "jo"], "range": { "start": "-12d", "end": "+2d" }, "includeDrafts": true, - "staleDays": 5 + "staleDays": 5, + "sprints": { "pattern": "Sprint {n}", "first": { "number": 24, "start": "-12d" }, "lengthDays": 15 } }, "open": [ { "repo": "api", "number": 482, "title": "Refresh OAuth tokens before they expire", "author": "dana", "ci": "failure", "createdAt": "-1d", "updatedAt": "-5h", "lastCommitAt": "-7h", "requestedReviewers": ["lee"], "comments": 4, "additions": 212, "deletions": 38 }, diff --git a/desktop/package.json b/desktop/package.json index defa3ba..8b807b4 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -7,7 +7,7 @@ "main": "dist/main/main/main.js", "scripts": { "build:main": "tsc -p tsconfig.json", - "test": "node src/main/core/oauth.service.test.mjs && node src/main/core/config.service.test.mjs && node src/main/core/github.service.test.mjs && node src/main/core/snapshot.store.test.mjs && node src/main/core/attention.test.mjs && node src/main/core/tray.text.test.mjs && node src/main/core/fixture.test.mjs && node renderer/scripts/checks.test.mjs", + "test": "node src/main/core/oauth.service.test.mjs && node src/main/core/config.service.test.mjs && node src/main/core/github.service.test.mjs && node src/main/core/snapshot.store.test.mjs && node src/main/core/attention.test.mjs && node src/main/core/tray.text.test.mjs && node src/main/core/fixture.test.mjs && node src/main/core/sprints.test.mjs && node renderer/scripts/checks.test.mjs", "start": "electron .", "package": "electron-builder", "package:win": "electron-builder --win", diff --git a/desktop/renderer/src/app/board.store.ts b/desktop/renderer/src/app/board.store.ts index 6ffc1ba..6c35636 100644 --- a/desktop/renderer/src/app/board.store.ts +++ b/desktop/renderer/src/app/board.store.ts @@ -276,6 +276,8 @@ export class BoardStore { range: active?.range ?? { start: new Date().toISOString().slice(0, 10), end: null }, includeDrafts: false, staleDays: active?.staleDays ?? 5, + sprints: active?.sprints ?? null, + period: active?.sprints ? 'current' : 'custom', }; const next = { ...cfg, profiles: [...cfg.profiles, profile], activeProfileId: profile.id }; this.config.set(next); diff --git a/desktop/renderer/src/app/models.ts b/desktop/renderer/src/app/models.ts index 45bf698..ec7d6bc 100644 --- a/desktop/renderer/src/app/models.ts +++ b/desktop/renderer/src/app/models.ts @@ -10,6 +10,35 @@ export interface DateRange { end: string | null; } +export interface SprintSchedule { + pattern: string; + first: { number: number; start: string }; + lengthDays: number; + lengths: Record; + names: Record; +} + +export type Period = 'current' | { sprint: number } | 'custom'; + +export interface Sprint { + number: number; + name: string; + start: string; + end: string; +} + +export interface ResolvedPeriod { + kind: 'sprint' | 'custom'; + label: string; + range: DateRange; + sprint: Sprint | null; + isCurrent: boolean; + previous: Sprint | null; + next: Sprint | null; + current: Sprint | null; + hasSchedule: boolean; +} + export interface Profile { id: string; name: string; @@ -18,6 +47,8 @@ export interface Profile { range: DateRange; includeDrafts: boolean; staleDays: number; + sprints: SprintSchedule | null; + period: Period; } export type ProfilePatch = Partial>; @@ -137,6 +168,7 @@ export interface PrSweepApi { */ fetchPrs(range: DateRange, mode?: 'full' | 'auto'): Promise; latestSweep(): Promise; + resolvePeriod(): Promise; syncTray(sync: { queue: PrRow[]; mine: PrRow[]; needsReviewCount: number; attentionCount: number }): Promise; openExternal(url: string): Promise; onUpdateState(cb: (state: UpdateState | null) => void): void; diff --git a/desktop/src/main/core/config.service.test.mjs b/desktop/src/main/core/config.service.test.mjs index 660bf36..b513655 100644 --- a/desktop/src/main/core/config.service.test.mjs +++ b/desktop/src/main/core/config.service.test.mjs @@ -52,4 +52,45 @@ const svc = new ConfigService(join(dir, 'w.json')); const saved = svc.set({ activeProfileId: 'nope' }); assert.equal(saved.activeProfileId, saved.profiles[0].id); +// Profiles from before v0.12 get no sprint schedule and keep their custom range. +{ + const file = join(dir, 'v011.json'); + writeFileSync( + file, + JSON.stringify({ + profiles: [{ id: 'a', name: 'A', org: 'acme', authors: [], range: { start: '2026-09-01', end: null }, includeDrafts: false, staleDays: 5 }], + activeProfileId: 'a', + }), + ); + const prof = activeProfile(new ConfigService(file).get()); + assert.strictEqual(prof.sprints, null); + assert.equal(prof.period, 'custom'); + assert.deepEqual(prof.range, { start: '2026-09-01', end: null }, 'range untouched'); +} + +// A sprint schedule and a pinned period round-trip; broken ones fall back safely. +{ + const sprints = { pattern: 'Sprint {n}', first: { number: 24, start: '2026-09-14' }, lengthDays: 14, lengths: { 30: 21 }, names: { 30: 'Holiday sprint' } }; + const file = join(dir, 'sprints.json'); + const svc2 = new ConfigService(file); + svc2.set({ profiles: [{ ...activeProfile(svc2.get()), sprints, period: { sprint: 25 } }] }); + const back = activeProfile(new ConfigService(file).get()); + assert.deepEqual(back.sprints, sprints); + assert.deepEqual(back.period, { sprint: 25 }); + + const broken = join(dir, 'broken.json'); + writeFileSync( + broken, + JSON.stringify({ + profiles: [ + { id: 'b', name: 'B', org: 'acme', authors: [], range: { start: '2026-09-01', end: null }, sprints: { ...sprints, lengthDays: 0 }, period: { sprint: 'x' } }, + ], + activeProfileId: 'b', + }), + ); + const fixed = activeProfile(new ConfigService(broken).get()); + assert.strictEqual(fixed.sprints, null, 'a zero-length sprint schedule is dropped'); + assert.equal(fixed.period, 'custom', 'an unreadable period falls back to the custom range'); +} + console.log('config.service: migration + activeProfile cases pass'); diff --git a/desktop/src/main/core/config.service.ts b/desktop/src/main/core/config.service.ts index 134b0a9..6cabb69 100644 --- a/desktop/src/main/core/config.service.ts +++ b/desktop/src/main/core/config.service.ts @@ -4,7 +4,7 @@ * class — no Electron imports — so it stays testable; main.ts supplies the path. */ import { existsSync, readFileSync, writeFileSync } from 'fs'; -import { DateRange, Profile, SweepConfig, SweepConfigPatch } from '../../shared/types'; +import { DateRange, Period, Profile, SprintSchedule, SweepConfig, SweepConfigPatch } from '../../shared/types'; function rollingStart(): string { return new Date(Date.now() - 30 * 86_400_000).toISOString().slice(0, 10); @@ -19,6 +19,8 @@ function defaultProfile(): Profile { range: { start: rollingStart(), end: null }, includeDrafts: false, staleDays: 5, + sprints: null, + period: 'custom', }; } @@ -104,9 +106,39 @@ function normalizeProfile(p: Record): Profile { range: { start: range.start || d.range.start, end: range.end ?? null }, includeDrafts: (p['includeDrafts'] as boolean) ?? d.includeDrafts, staleDays: (p['staleDays'] as number) ?? d.staleDays, + // Profiles from before v0.12 have neither: no schedule, their own range. + sprints: normalizeSprints(p['sprints']), + period: normalizePeriod(p['period']), }; } +/** A schedule that can be computed from, or null. A broken one is dropped, never guessed at. */ +function normalizeSprints(v: unknown): SprintSchedule | null { + if (!v || typeof v !== 'object') return null; + const s = v as Record; + const first = (s['first'] ?? {}) as Record; + const days = (n: unknown): n is number => Number.isInteger(n) && (n as number) >= 1 && (n as number) <= 60; + if (typeof first['start'] !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(first['start'])) return null; + if (!Number.isInteger(first['number']) || (first['number'] as number) < 0) return null; + if (!days(s['lengthDays'])) return null; + const byNumber = (m: unknown, ok: (x: unknown) => x is T): Record => + Object.fromEntries(Object.entries((m ?? {}) as Record).filter(([k, x]) => /^\d+$/.test(k) && ok(x))) as Record; + const pattern = typeof s['pattern'] === 'string' && s['pattern'].trim() ? s['pattern'] : 'Sprint {n}'; + return { + pattern, + first: { number: first['number'] as number, start: first['start'] }, + lengthDays: s['lengthDays'] as number, + lengths: byNumber(s['lengths'], days), + names: byNumber(s['names'], (x): x is string => typeof x === 'string' && x.trim() !== ''), + }; +} + +function normalizePeriod(v: unknown): Period { + if (v === 'current' || v === 'custom') return v; + const sprint = (v as { sprint?: unknown } | null)?.sprint; + return Number.isInteger(sprint) ? { sprint: sprint as number } : 'custom'; +} + /** Accepts a v0.6 flat range, or a pre-v0.6 sprint list covering today. */ function migrateRange(stored: Record): DateRange | undefined { const range = stored['range'] as DateRange | undefined; diff --git a/desktop/src/main/core/fixture.test.mjs b/desktop/src/main/core/fixture.test.mjs index b6a7158..101c399 100644 --- a/desktop/src/main/core/fixture.test.mjs +++ b/desktop/src/main/core/fixture.test.mjs @@ -8,6 +8,7 @@ import assert from 'node:assert'; import { readFileSync } from 'node:fs'; import { annotate } from '../../../dist/main/main/core/attention.js'; import { loadFixture, relativeDate, relativeTime } from '../../../dist/main/main/core/fixture.js'; +import { localDate as today, resolvePeriod } from '../../../dist/main/main/core/sprints.js'; const NOW = Date.parse('2026-09-29T15:00:00Z'); const HOUR = 3_600_000; @@ -65,6 +66,26 @@ assert.throws( /bad: open\[0\]\.bucket "stuck" is not one of/, ); +// --- a sprint schedule passes through, its first start relative; the period defaults to current --- +{ + const f = loadFixture( + 'sprinting', + read({ + sprinting: { + profile: { + org: 'acme', authors: [], range: { start: '-12d', end: null }, + sprints: { pattern: 'Sprint {n}', first: { number: 24, start: '-12d' }, lengthDays: 15 }, + }, + }, + }), + NOW, + ); + assert.deepEqual(f.profile.sprints, { + pattern: 'Sprint {n}', first: { number: 24, start: localDate(NOW - 12 * 24 * HOUR) }, lengthDays: 15, lengths: {}, names: {}, + }); + assert.equal(f.profile.period, 'current'); +} + // --- extends: the base fixture, with the extending one's fields on top --- { const f = loadFixture( @@ -84,9 +105,13 @@ assert.throws( { const files = (name) => JSON.parse(readFileSync(new URL(`../../../e2e/fixtures/${name}.json`, import.meta.url), 'utf8')); const f = loadFixture('busy', files, Date.now()); + // busy runs on a sprint schedule: the current sprint ends in two days. + const period = resolvePeriod({ id: 'x', name: 'x', ...f.profile }, today()); + assert.equal(period.kind, 'sprint'); + assert.equal(period.isCurrent, true); const judged = annotate( - { schema: 0, fetchedAt: '', org: f.profile.org, range: f.profile.range, ...f.sweep, sprintRisk: null }, - { now: Date.now(), staleDays: f.profile.staleDays, rangeEnd: f.profile.range.end }, + { schema: 0, fetchedAt: '', org: f.profile.org, range: period.range, ...f.sweep, sprintRisk: null }, + { now: Date.now(), staleDays: f.profile.staleDays, rangeEnd: period.range.end }, ); const reasons = new Set(judged.open.flatMap((r) => r.attention.map((a) => a.reason))); assert.deepEqual( diff --git a/desktop/src/main/core/fixture.ts b/desktop/src/main/core/fixture.ts index dc94cd7..17068c1 100644 --- a/desktop/src/main/core/fixture.ts +++ b/desktop/src/main/core/fixture.ts @@ -6,7 +6,7 @@ * Rows list only what matters to them; everything else gets a default. Pure: * the caller supplies how a fixture name is read. */ -import { PrRow, ReviewBucket, UpdateState } from '../../shared/types'; +import { Period, PrRow, ReviewBucket, SprintSchedule, UpdateState } from '../../shared/types'; export interface FixtureProfile { org: string; @@ -14,6 +14,10 @@ export interface FixtureProfile { range: { start: string; end: string | null }; includeDrafts: boolean; staleDays: number; + /** A schedule's first start may be relative too. */ + sprints: SprintSchedule | null; + /** Defaults to 'current' when there's a schedule. */ + period: Period; } export interface Fixture { @@ -69,6 +73,8 @@ export function loadFixture(name: string, read: (name: string) => unknown, now: const range = (profile.range ?? {}) as Json; if (typeof range.start !== 'string') throw new Error(where('profile.range.start is required')); const org = String(profile.org ?? ''); + const sprints = profile.sprints as Json | undefined; + const first = (sprints?.first ?? {}) as Json; const rows = (key: 'open' | 'merged' | 'queue', bucket: ReviewBucket): PrRow[] => ((raw[key] ?? []) as Json[]).map((r, i) => toRow(r, `${key}[${i}]`, org, bucket, now, where)); return { @@ -81,6 +87,16 @@ export function loadFixture(name: string, read: (name: string) => unknown, now: }, includeDrafts: Boolean(profile.includeDrafts), staleDays: typeof profile.staleDays === 'number' ? profile.staleDays : 5, + sprints: sprints + ? { + pattern: String(sprints.pattern ?? 'Sprint {n}'), + first: { number: Number(first.number), start: relativeDate(String(first.start), now) }, + lengthDays: Number(sprints.lengthDays), + lengths: (sprints.lengths ?? {}) as Record, + names: (sprints.names ?? {}) as Record, + } + : null, + period: (profile.period as Period | undefined) ?? (sprints ? 'current' : 'custom'), }, auth: raw.auth === 'no-token' ? 'no-token' : 'signed-in', sweep: { open: rows('open', 'needs-review'), merged: rows('merged', 'merged'), queue: rows('queue', 'needs-review') }, diff --git a/desktop/src/main/core/sprints.test.mjs b/desktop/src/main/core/sprints.test.mjs new file mode 100644 index 0000000..114df66 --- /dev/null +++ b/desktop/src/main/core/sprints.test.mjs @@ -0,0 +1,111 @@ +/** + * Verifies the sprint schedule: numbering and dates (last day included), + * one-off lengths shifting later sprints, renames, which sprint holds a date, + * and resolving a profile's period to what the board shows. + * Run after `npm run build:main`: node src/main/core/sprints.test.mjs + */ +import assert from 'node:assert'; +import { resolvePeriod, sprintAt, sprintOn } from '../../../dist/main/main/core/sprints.js'; + +const schedule = (patch = {}) => ({ + pattern: 'Sprint {n}', + first: { number: 24, start: '2026-09-14' }, + lengthDays: 14, + lengths: {}, + names: {}, + ...patch, +}); + +// --- numbering and dates, last day included --- +assert.deepEqual(sprintAt(schedule(), 24), { number: 24, name: 'Sprint 24', start: '2026-09-14', end: '2026-09-27' }); +assert.deepEqual(sprintAt(schedule(), 25), { number: 25, name: 'Sprint 25', start: '2026-09-28', end: '2026-10-11' }); +assert.strictEqual(sprintAt(schedule(), 23), null, 'nothing before the first sprint'); +assert.equal(sprintAt(schedule(), 60).start, '2028-01-31', '36 sprints later, across two DST changes, still exact'); + +// --- a one-off length shifts every later sprint --- +{ + const s = schedule({ lengths: { 25: 21 } }); + assert.deepEqual(sprintAt(s, 25), { number: 25, name: 'Sprint 25', start: '2026-09-28', end: '2026-10-18' }); + assert.equal(sprintAt(s, 26).start, '2026-10-19'); + assert.equal(sprintAt(s, 26).end, '2026-11-01', 'back to the usual length'); +} + +// --- a one-off name --- +assert.equal(sprintAt(schedule({ names: { 25: 'Holiday sprint' } }), 25).name, 'Holiday sprint'); +assert.equal(sprintAt(schedule({ pattern: 'Iteration {n}' }), 30).name, 'Iteration 30'); + +// --- which sprint holds a date --- +assert.equal(sprintOn(schedule(), '2026-09-27').number, 24, 'last day belongs to the sprint'); +assert.equal(sprintOn(schedule(), '2026-09-28').number, 25, 'the next day starts the next one'); +assert.strictEqual(sprintOn(schedule(), '2026-09-13'), null, 'before the schedule'); + +// --- resolving a profile's period --- +const profile = (patch = {}) => ({ + id: 'p', + name: 'Team', + org: 'acme', + authors: [], + range: { start: '2026-09-17', end: '2026-10-01' }, + includeDrafts: false, + staleDays: 5, + sprints: schedule(), + period: 'current', + ...patch, +}); +const TODAY = '2026-09-29'; + +{ + const p = resolvePeriod(profile(), TODAY); + assert.equal(p.kind, 'sprint'); + assert.equal(p.sprint.number, 25); + assert.equal(p.isCurrent, true); + assert.deepEqual(p.range, { start: '2026-09-28', end: '2026-10-11' }); + assert.equal(p.label, 'Sprint 25 · Sep 28 – Oct 11'); + assert.equal(p.previous.number, 24); + assert.equal(p.next.number, 26); + assert.equal(p.current.number, 25); + assert.equal(p.hasSchedule, true); +} + +{ + const p = resolvePeriod(profile({ period: { sprint: 24 } }), TODAY); + assert.equal(p.sprint.number, 24); + assert.equal(p.isCurrent, false, 'a pinned past sprint'); + assert.equal(p.current.number, 25, 'the current one is still known, for "Current"'); + assert.equal(p.label, 'Sprint 24 · Sep 14–27', 'same month: one month name'); + assert.strictEqual(p.previous, null, 'nothing before the first sprint'); +} + +{ + const p = resolvePeriod(profile({ period: 'custom' }), TODAY); + assert.equal(p.kind, 'custom'); + assert.deepEqual(p.range, { start: '2026-09-17', end: '2026-10-01' }); + assert.equal(p.label, 'Sep 17 – Oct 1'); + assert.strictEqual(p.sprint, null); + assert.equal(p.hasSchedule, true); +} + +{ + const p = resolvePeriod(profile({ period: 'custom', range: { start: '2026-09-17', end: null } }), TODAY); + assert.equal(p.label, 'Since Sep 17', 'open-ended custom range'); +} + +{ + const p = resolvePeriod(profile({ sprints: null }), TODAY); + assert.equal(p.kind, 'custom', 'no schedule: the custom range, whatever the period says'); + assert.equal(p.hasSchedule, false); +} + +{ + const p = resolvePeriod(profile({ sprints: schedule({ first: { number: 1, start: '2026-10-05' } }) }), TODAY); + assert.equal(p.sprint.number, 1, 'before the schedule starts, "current" shows the first sprint'); + assert.equal(p.isCurrent, false); + assert.strictEqual(p.current, null); +} + +{ + const p = resolvePeriod(profile({ sprints: schedule({ first: { number: 1, start: '2026-12-21' } }), period: { sprint: 2 } }), TODAY); + assert.equal(p.label, 'Sprint 2 · Jan 4–17, 2027', 'the year shows when it isn’t this one'); +} + +console.log('sprints: schedule math, current sprint and period resolution pass'); diff --git a/desktop/src/main/core/sprints.ts b/desktop/src/main/core/sprints.ts new file mode 100644 index 0000000..68b3fd8 --- /dev/null +++ b/desktop/src/main/core/sprints.ts @@ -0,0 +1,100 @@ +/** + * Sprint schedules: every sprint is computed from a profile's schedule, none is + * stored, so "Current" rolls over by itself and nobody keeps a list up to date + * (the reason sprints were removed on 2026-08-27). Dates are local calendar + * days (yyyy-mm-dd), and a sprint's end is its last day. Pure: "today" comes + * from the caller. + */ +import { DateRange, Profile, ResolvedPeriod, Sprint, SprintSchedule } from '../../shared/types'; + +const DAY = 86_400_000; +/** Guards the walks below against a runaway schedule. */ +const MAX_SPRINTS = 2000; +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +// yyyy-mm-dd arithmetic through UTC midnights, so DST never shifts a day. +const addDays = (date: string, days: number): string => + new Date(Date.parse(`${date}T00:00:00Z`) + days * DAY).toISOString().slice(0, 10); +const lengthOf = (s: SprintSchedule, n: number): number => s.lengths[n] ?? s.lengthDays; +const nameOf = (s: SprintSchedule, n: number): string => s.names[n] ?? s.pattern.replace('{n}', String(n)); + +/** Today's local date as yyyy-mm-dd, like the header's date inputs. */ +export function localDate(ms: number = Date.now()): string { + const d = new Date(ms); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +/** Sprint `n`, or null before the schedule's first sprint. */ +export function sprintAt(s: SprintSchedule, n: number): Sprint | null { + if (!Number.isInteger(n) || n < s.first.number || n - s.first.number > MAX_SPRINTS) return null; + let start = s.first.start; + for (let k = s.first.number; k < n; k++) start = addDays(start, lengthOf(s, k)); + return { number: n, name: nameOf(s, n), start, end: addDays(start, lengthOf(s, n) - 1) }; +} + +/** The sprint whose days include `date`, or null before the schedule starts. */ +export function sprintOn(s: SprintSchedule, date: string): Sprint | null { + if (date < s.first.start) return null; + let start = s.first.start; + for (let n = s.first.number; n - s.first.number <= MAX_SPRINTS; n++) { + const end = addDays(start, lengthOf(s, n) - 1); + if (date <= end) return { number: n, name: nameOf(s, n), start, end }; + start = addDays(end, 1); + } + return null; +} + +/** What the board shows for a profile today: its sprint (current or pinned), or its custom range. */ +export function resolvePeriod(profile: Profile, today: string): ResolvedPeriod { + const s = profile.sprints; + const year = Number(today.slice(0, 4)); + const current = s ? sprintOn(s, today) : null; + if (!s || profile.period === 'custom') { + return { + kind: 'custom', + label: rangeLabel(profile.range, year), + range: profile.range, + sprint: null, + isCurrent: false, + previous: null, + next: null, + current, + hasSchedule: !!s, + }; + } + // Before the schedule starts, "Current" shows its first sprint; a pinned + // sprint that no longer exists (the schedule moved) does the same. + const pinned = profile.period === 'current' ? current : sprintAt(s, profile.period.sprint); + const sprint = pinned ?? (sprintAt(s, s.first.number) as Sprint); + return { + kind: 'sprint', + label: `${sprint.name} · ${span(sprint.start, sprint.end, year)}`, + range: { start: sprint.start, end: sprint.end }, + sprint, + isCurrent: current?.number === sprint.number, + previous: sprintAt(s, sprint.number - 1), + next: sprintAt(s, sprint.number + 1), + current, + hasSchedule: true, + }; +} + +function parts(date: string): { y: number; m: string; d: number } { + const [y, m, d] = date.split('-').map(Number); + return { y, m: MONTHS[m - 1], d }; +} + +/** "Sep 14–27", "Sep 28 – Oct 11", with the year when it isn't this one. */ +function span(start: string, end: string, thisYear: number): string { + const a = parts(start); + const b = parts(end); + if (a.y !== b.y) return `${a.m} ${a.d}, ${a.y} – ${b.m} ${b.d}, ${b.y}`; + const year = a.y !== thisYear ? `, ${a.y}` : ''; + return a.m === b.m ? `${a.m} ${a.d}–${b.d}${year}` : `${a.m} ${a.d} – ${b.m} ${b.d}${year}`; +} + +function rangeLabel(range: DateRange, thisYear: number): string { + if (range.end) return span(range.start, range.end, thisYear); + const a = parts(range.start); + return `Since ${a.m} ${a.d}${a.y !== thisYear ? `, ${a.y}` : ''}`; +} diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 4eb9156..cf5b038 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -11,6 +11,7 @@ import { readFileSync, writeFileSync } from 'fs'; import { annotate } from './core/attention'; import { activeProfile, ConfigService } from './core/config.service'; import { Fixture } from './core/fixture'; +import { localDate, resolvePeriod } from './core/sprints'; import { GithubService, SweepStats } from './core/github.service'; import { DEFAULT_OAUTH_CLIENT_ID } from './core/oauth.constants'; import { pollForToken, requestDeviceCode } from './core/oauth.service'; @@ -81,6 +82,7 @@ export function registerIpc(services: Services): void { } }); // Fixture mode starts empty, so the first sweep's loading state is visible. + ipcMain.handle('period:resolve', () => resolvePeriod(activeProfile(services.config.get()), localDate())); ipcMain.handle('prs:latest', () => (services.fixture ? null : services.snapshots.get())); ipcMain.handle('shell:open', (_e, url: string) => { diff --git a/desktop/src/preload/preload.ts b/desktop/src/preload/preload.ts index c27da69..f6d5c7c 100644 --- a/desktop/src/preload/preload.ts +++ b/desktop/src/preload/preload.ts @@ -27,6 +27,7 @@ const api: PrSweepApi = { installUpdate: () => ipcRenderer.invoke('update:install'), fetchPrs: (range, mode) => ipcRenderer.invoke('prs:fetch', range, mode), latestSweep: () => ipcRenderer.invoke('prs:latest'), + resolvePeriod: () => ipcRenderer.invoke('period:resolve'), syncTray: (sync) => ipcRenderer.invoke('tray:sync', sync), openExternal: (url) => ipcRenderer.invoke('shell:open', url), exportProfiles: () => ipcRenderer.invoke('config:export'), diff --git a/desktop/src/shared/types.ts b/desktop/src/shared/types.ts index 7c24e7a..7abfb05 100644 --- a/desktop/src/shared/types.ts +++ b/desktop/src/shared/types.ts @@ -15,6 +15,54 @@ export interface DateRange { end: string | null; } +/** + * A profile's sprint schedule. Every sprint is computed from it (core/sprints.ts); + * none is stored, so "Current" rolls over by itself. + */ +export interface SprintSchedule { + /** Name pattern; {n} becomes the sprint number: "Sprint {n}". */ + pattern: string; + /** The first sprint: its number and start date (local yyyy-mm-dd). */ + first: { number: number; start: string }; + /** Days per sprint, 1–60. */ + lengthDays: number; + /** One-off lengths by sprint number; later sprints shift to follow. */ + lengths: Record; + /** One-off names by sprint number. */ + names: Record; +} + +/** What a profile's board looks at: today's sprint, a pinned sprint, or its own date range. */ +export type Period = 'current' | { sprint: number } | 'custom'; + +export interface Sprint { + number: number; + name: string; + /** First and last day, local yyyy-mm-dd, both included. */ + start: string; + end: string; +} + +/** A profile's period resolved against today by the main process (see period:resolve). */ +export interface ResolvedPeriod { + kind: 'sprint' | 'custom'; + /** "Sprint 24 · Sep 14–27", "Sep 17 – Oct 1" or "Since Sep 17". */ + label: string; + /** What the sweep covers: the sprint's days, or the custom range. */ + range: DateRange; + /** The sprint shown, when kind is 'sprint'. */ + sprint: Sprint | null; + /** The sprint shown is the one containing today. */ + isCurrent: boolean; + /** Neighbours for the picker's arrows; null before the first sprint. */ + previous: Sprint | null; + next: Sprint | null; + /** The sprint containing today, if the schedule has started. */ + current: Sprint | null; + /** False until the profile has a sprint schedule. */ + hasSchedule: boolean; +} + /** * A saved board definition — "which org + team + window am I looking at". The * shareable unit: exporting config exports profiles (never tokens or machine @@ -31,6 +79,9 @@ export interface Profile { includeDrafts: boolean; /** Flag open PRs untouched for this many days. 0 disables. */ staleDays: number; + /** Null until the team sets one up in Settings. */ + sprints: SprintSchedule | null; + period: Period; } export type ProfilePatch = Partial>; @@ -181,6 +232,8 @@ export interface PrSweepApi { fetchPrs(range: DateRange, mode?: 'full' | 'auto'): Promise; /** Last sweep cached on disk, or null — for instant boot before the live refresh lands. */ latestSweep(): Promise; + /** The active profile's period (sprint or custom range) resolved against today. */ + resolvePeriod(): Promise; /** * Push the latest sweep's tray-relevant slices: the review queue (counts + * review-request toasts), the viewer's own open PRs (approval / changes- From 4511f35e90a756d2c872e3c8cc24f86846cf2b75 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Tue, 29 Sep 2026 13:08:19 -0500 Subject: [PATCH 07/30] feat(ui): Quorum top bar with the sprint picker, freshness, loading bar and error banner --- ROADMAP.md | 2 +- desktop/renderer/scripts/check-contrast.mjs | 8 +- desktop/renderer/src/app/app.component.ts | 165 +++++++++--------- desktop/renderer/src/app/board.store.ts | 63 +++++-- .../src/app/period-picker.component.ts | 104 +++++++++++ .../src/app/ui/freshness.component.ts | 43 +++++ desktop/renderer/src/app/ui/icons.ts | 10 +- desktop/renderer/src/styles.css | 71 +------- desktop/renderer/src/styles/app/controls.css | 133 ++++++++++++++ desktop/renderer/src/styles/app/shell.css | 91 ++++++++++ 10 files changed, 529 insertions(+), 161 deletions(-) create mode 100644 desktop/renderer/src/app/period-picker.component.ts create mode 100644 desktop/renderer/src/app/ui/freshness.component.ts create mode 100644 desktop/renderer/src/styles/app/controls.css create mode 100644 desktop/renderer/src/styles/app/shell.css diff --git a/ROADMAP.md b/ROADMAP.md index 5e88c20..5208e68 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -304,7 +304,7 @@ on the `release/v0.12` branch. 949 KB.* - [x] Sprints core: `core/sprints.ts` (schedule math, the current sprint, period resolution), `sprints` and `period` on each profile with a migration, and a `period:resolve` IPC call. -- [ ] Shell: top bar with the sprint picker, freshness indicator, loading bar, tabs, theme +- [x] Shell: top bar with the sprint picker, freshness indicator, loading bar, tabs, theme toggle, update pill and error banner. - [ ] Board: health strip, filter bar, tables with density, CI status badges, Sweep rows, empty states and skeleton rows. diff --git a/desktop/renderer/scripts/check-contrast.mjs b/desktop/renderer/scripts/check-contrast.mjs index 3a83d9a..85215e7 100644 --- a/desktop/renderer/scripts/check-contrast.mjs +++ b/desktop/renderer/scripts/check-contrast.mjs @@ -23,10 +23,14 @@ const PAIRS = [ [`${st}-text`, `${st}-subtle over bg-2`, 4.5], // status badge label on its tint ]), // Banners sit on the page; the app raises errors (critical) and updates (info). - ...['critical', 'info'].map((st) => [`${st}-text`, `${st}-subtle over bg-0`, 4.5]), + ...['critical', 'info'].flatMap((st) => [ + [`${st}-text`, `${st}-subtle over bg-0`, 4.5], + ['text-1', `${st}-subtle over bg-0`, 4.5], + ['text-2', `${st}-subtle over bg-0`, 4.5], + ]), ['accent', 'bg-0', 3], // focus ring ['accent', 'bg-2', 3], - ...['healthy', 'warning', 'critical', 'running'].map((st) => [st, 'bg-2', 3]), // status dots + ...['healthy', 'warning', 'critical', 'running'].flatMap((st) => [[st, 'bg-1', 3], [st, 'bg-2', 3]]), // status dots ]; function declarations(text) { diff --git a/desktop/renderer/src/app/app.component.ts b/desktop/renderer/src/app/app.component.ts index f13ada4..293b562 100644 --- a/desktop/renderer/src/app/app.component.ts +++ b/desktop/renderer/src/app/app.component.ts @@ -2,89 +2,96 @@ import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } import { NavigationEnd, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { BoardStore } from './board.store'; import { OnboardingComponent } from './onboarding.component'; +import { PeriodPickerComponent } from './period-picker.component'; +import { FreshnessComponent } from './ui/freshness.component'; +import { IconComponent } from './ui/icon.component'; type Theme = 'light' | 'dark'; const THEME_KEY = 'prsweep-theme'; /** - * Root shell: no branding — the header-left is a title describing what the - * current page shows (the selected sprint on the board, "Settings" there). - * Sprint picker, refresh, and the light/dark toggle live here so they're - * reachable from any page — everything binds to the shared BoardStore. + * Root shell: Quorum's top bar. Left, what this page is and the Board/Settings + * tabs; right, how fresh the data is, which period it covers, Refresh and the + * theme. Everything binds to the shared BoardStore, so the controls work from + * any page. */ @Component({ selector: 'app-root', standalone: true, - imports: [RouterOutlet, RouterLink, RouterLinkActive, OnboardingComponent], + imports: [RouterOutlet, RouterLink, RouterLinkActive, OnboardingComponent, PeriodPickerComponent, FreshnessComponent, IconComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
-
-

{{ pageTitle() }}

-
- @if (store.profiles().length > 1) { - - } - @if (store.updateState(); as update) { - @if (update.status === 'downloading') { - - Update v{{ update.version }} — {{ update.percent }}% - - } @else { - - } - } - @if (store.fetchedAgeMin() !== null) { - updated {{ ageLabel(store.fetchedAgeMin()!) }} - } - -
+ } + + @if (store.updateState(); as update) { + @if (update.status === 'downloading') { + + + Downloading v{{ update.version }} · {{ update.percent }}% + + } @else { + + } + } + @if (store.result() || store.loading()) { + + } + + + + @if (store.loading()) { + + }
@if (store.error(); as err) { -
{{ err }}
+ }
@@ -103,14 +110,9 @@ export class AppComponent { readonly pageTitle = computed(() => { if (this.url().includes('settings')) return 'Settings'; - const range = this.store.range(); // Prefix the active profile's name only when there's more than one. const profiles = this.store.profiles(); - const prefix = profiles.length > 1 ? `${this.store.activeProfile()?.name} · ` : ''; - if (!range?.start) return `${prefix}Pull requests`; - return range.end - ? `${prefix}Pull requests — ${dateLabel(range.start)} to ${dateLabel(range.end)}` - : `${prefix}Pull requests — since ${dateLabel(range.start)}`; + return profiles.length > 1 ? `${this.store.activeProfile()?.name} · Pull requests` : 'Pull requests'; }); constructor() { @@ -122,7 +124,8 @@ export class AppComponent { }); effect(() => { document.documentElement.dataset['theme'] = this.theme(); - document.title = this.pageTitle(); + const period = this.store.period()?.label; + document.title = period && !this.url().includes('settings') ? `${this.pageTitle()} · ${period}` : this.pageTitle(); localStorage.setItem(THEME_KEY, this.theme()); }); } @@ -131,20 +134,20 @@ export class AppComponent { this.theme.set(this.otherTheme()); } - ageLabel(min: number): string { + /** "2m ago" since the last successful sweep; null before the first. */ + ageLabel(): string | null { + const min = this.store.fetchedAgeMin(); + if (min === null) return null; if (min < 1) return 'just now'; if (min < 60) return `${min}m ago`; const h = Math.floor(min / 60); return h < 48 ? `${h}h ago` : `${Math.floor(h / 24)}d ago`; } -} -/** "2026-08-09" → "Aug 9" (with year when it isn't the current one). */ -function dateLabel(iso: string): string { - const d = new Date(`${iso}T00:00:00`); - const opts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' }; - if (d.getFullYear() !== new Date().getFullYear()) opts.year = 'numeric'; - return d.toLocaleDateString('en-US', opts); + fetchedAtTitle(): string | null { + const ts = this.store.result()?.fetchedAt; + return ts ? `Last swept ${new Date(ts).toLocaleString()}` : null; + } } /** Stored choice wins; first run follows the OS. */ diff --git a/desktop/renderer/src/app/board.store.ts b/desktop/renderer/src/app/board.store.ts index 6c35636..53b7e9b 100644 --- a/desktop/renderer/src/app/board.store.ts +++ b/desktop/renderer/src/app/board.store.ts @@ -2,8 +2,10 @@ import { Injectable, computed, signal } from '@angular/core'; import { AuthStatus, DateRange, + Period, PrRow, Profile, + ResolvedPeriod, ProfilePatch, SweepConfig, SweepConfigPatch, @@ -33,6 +35,12 @@ export class BoardStore { readonly search = signal(''); /** Auto-update progress pushed from main (header pill); null = nothing in flight. */ readonly updateState = signal(null); + /** The active profile's sprint or custom range, resolved by main on every refresh. */ + readonly period = signal(null); + /** The last refresh failed, background ones included (they don't raise the banner). */ + readonly lastFailed = signal(false); + /** Ticks every 30 s so relative ages ("2m ago") stay true between refreshes. */ + readonly clock = signal(Date.now()); private refreshTimer: ReturnType | null = null; @@ -45,7 +53,8 @@ export class BoardStore { return cfg.profiles.find((p) => p.id === cfg.activeProfileId) ?? cfg.profiles[0] ?? null; }); - readonly range = computed(() => this.activeProfile()?.range ?? null); + /** What the sweep covers: the resolved sprint or custom range. */ + readonly range = computed(() => this.period()?.range ?? this.activeProfile()?.range ?? null); /** True until a token is stored and fully working — drives the onboarding overlay. */ readonly needsToken = computed(() => { @@ -97,7 +106,16 @@ export class BoardStore { readonly fetchedAgeMin = computed(() => { const ts = this.result()?.fetchedAt; - return ts ? Math.max(0, Math.round((Date.now() - Date.parse(ts)) / 60000)) : null; + return ts ? Math.max(0, Math.round((this.clock() - Date.parse(ts)) / 60000)) : null; + }); + + /** Quorum's Freshness: updating now, failed last time, overdue, or current. */ + readonly freshness = computed<'updated' | 'updating' | 'delayed' | 'lost'>(() => { + if (this.loading()) return 'updating'; + if (this.lastFailed()) return 'lost'; + const age = this.fetchedAgeMin(); + const every = this.config()?.autoRefreshMinutes ?? 0; + return every > 0 && age !== null && age > every * 2 ? 'delayed' : 'updated'; }); private slice(bucket: PrRow['bucket']): PrRow[] { @@ -117,19 +135,23 @@ export class BoardStore { async init(): Promise { this.api.onUpdateState((state) => this.updateState.set(state)); + setInterval(() => this.clock.set(Date.now()), 30_000); try { // Config + cached snapshot are local reads — paint the board with them // immediately. The auth probe and live sweep (both network) come after, // quietly replacing the stale data. const [config, snapshot] = await Promise.all([this.api.getConfig(), this.api.latestSweep()]); this.config.set(config); + await this.loadPeriod(); const p = this.activeProfile(); + const range = this.range(); if ( snapshot && p && + range && snapshot.org === p.org && - snapshot.range.start === p.range.start && - (snapshot.range.end ?? null) === (p.range.end ?? null) + snapshot.range.start === range.start && + (snapshot.range.end ?? null) === (range.end ?? null) ) { this.result.set(snapshot); } @@ -143,21 +165,27 @@ export class BoardStore { } async refresh(opts: { auto?: boolean } = {}): Promise { - const range = this.range(); - if (!range?.start || this.loading()) return; + if (this.loading()) return; this.loading.set(true); if (!opts.auto) this.error.set(null); try { + // Re-resolve first: on the first refresh after a sprint ends, "Current" + // moves to the next one (a new range, so main sweeps it in full). + await this.loadPeriod(); + const range = this.range(); + if (!range?.start) return; // Timer refreshes go incremental (cheap for big orgs); manual ones are // always a full resweep so Refresh doubles as the recovery lever. const result = await this.api.fetchPrs(range, opts.auto ? 'auto' : 'full'); this.result.set(result); this.error.set(null); + this.lastFailed.set(false); this.syncTray(result); } catch (e) { // A background refresh failing (laptop offline) shouldn't blank a board // that's already showing data — surface quietly only for manual actions. - if (!opts.auto) this.error.set((e as Error).message); + this.lastFailed.set(true); + if (!opts.auto) this.error.set(cleanError(e)); } finally { this.loading.set(false); } @@ -179,16 +207,26 @@ export class BoardStore { void this.refresh(); } - /** Persist a range edit and refetch. An empty end means open-ended. */ + /** Persist a custom-range edit and refetch. An empty end means open-ended. */ setRange(patch: Partial): void { - const current = this.range() ?? { start: '', end: null }; + const current = this.activeProfile()?.range ?? { start: '', end: null }; const next: DateRange = { ...current, ...patch }; if (!next.start) return; if (next.end && next.end < next.start) next.end = null; - this.patchProfile({ range: next }); + this.patchProfile({ range: next, period: 'custom' }); + void this.refresh(); + } + + /** Show the current sprint, a pinned one, or the custom range. */ + setPeriod(period: Period): void { + this.patchProfile({ period }); void this.refresh(); } + private async loadPeriod(): Promise { + this.period.set(await this.api.resolvePeriod()); + } + toggleAuthor(login: string): void { const next = new Set(this.authorFilter()); if (!next.delete(login)) next.add(login); @@ -427,6 +465,11 @@ function localDay(): string { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } +/** Electron wraps errors thrown in main ("Error invoking remote method 'prs:fetch': Error: …"); keep the message. */ +function cleanError(e: unknown): string { + return String((e as Error)?.message ?? e).replace(/^Error invoking remote method '[^']+': (?:Error: )?/, ''); +} + function bySeverityThenAge(a: PrRow, b: PrRow): number { const [x, y] = [a.attention[0], b.attention[0]]; return x.severity - y.severity || (x.since ?? a.updatedAt).localeCompare(y.since ?? b.updatedAt); diff --git a/desktop/renderer/src/app/period-picker.component.ts b/desktop/renderer/src/app/period-picker.component.ts new file mode 100644 index 0000000..2f41759 --- /dev/null +++ b/desktop/renderer/src/app/period-picker.component.ts @@ -0,0 +1,104 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { BoardStore } from './board.store'; +import { Sprint } from './models'; +import { IconComponent } from './ui/icon.component'; + +/** + * What the board covers: a sprint from the profile's schedule, stepped with the + * arrows (Quorum's TimeRangePicker, adapted to sprints), or a custom From/To + * range. The main process resolves the period; this only picks one. + */ +@Component({ + selector: 'app-period-picker', + standalone: true, + imports: [IconComponent, RouterLink], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (store.period(); as p) { +
+ @if (p.hasSchedule) { +
+ + +
+ } + @if (p.kind === 'sprint') { + + {{ p.label }} + + @if (!p.isCurrent && p.current) { + + } + } @else { + + + @if (!p.hasSchedule) { + Set up sprints + } + } +
+ } + `, +}) +export class PeriodPickerComponent { + readonly store = inject(BoardStore); + + /** Stepping to the sprint containing today is "Current", so it keeps rolling over. */ + show(sprint: Sprint | null): void { + if (!sprint) return; + const current = this.store.period()?.current; + this.store.setPeriod(current && current.number === sprint.number ? 'current' : { sprint: sprint.number }); + } +} diff --git a/desktop/renderer/src/app/ui/freshness.component.ts b/desktop/renderer/src/app/ui/freshness.component.ts new file mode 100644 index 0000000..786a379 --- /dev/null +++ b/desktop/renderer/src/app/ui/freshness.component.ts @@ -0,0 +1,43 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; + +export type FreshnessState = 'updated' | 'updating' | 'delayed' | 'lost'; + +const LABEL: Record = { + updated: 'Updated', + updating: 'Updating', + delayed: 'Delayed', + lost: 'Refresh failed', +}; + +/** + * Quorum's Freshness: whether the board is current. A dot (shape and color) + * plus a word, never color alone; the age is evidence, so it's mono. + */ +@Component({ + selector: 'q-freshness', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + + {{ label() }} + @if (age(); as a) { + {{ a }} + } + + `, +}) +export class FreshnessComponent { + readonly state = input.required(); + /** "2m ago"; null while nothing has been fetched yet. */ + readonly age = input(null); + readonly title = input(null); + + label(): string { + return LABEL[this.state()]; + } + + tone(): string { + return { updated: 'healthy', updating: 'running', delayed: 'warning', lost: 'critical' }[this.state()]; + } +} diff --git a/desktop/renderer/src/app/ui/icons.ts b/desktop/renderer/src/app/ui/icons.ts index ef6a546..5845957 100644 --- a/desktop/renderer/src/app/ui/icons.ts +++ b/desktop/renderer/src/app/ui/icons.ts @@ -3,9 +3,12 @@ * the app loads nothing from a CDN. Generated from lucide-static/icons/.svg; only the icons * the screens use are here. To add one, copy its elements from that file in the same shape. */ -export type IconShape = [tag: 'path' | 'circle' | 'line' | 'polyline' | 'polygon' | 'rect' | 'ellipse', attrs: Record]; +export type IconShape = [ + tag: 'path' | 'circle' | 'line' | 'polyline' | 'polygon' | 'rect' | 'ellipse', + attrs: Record, +]; -export const ICONS = { +const DATA = { 'refresh-cw': [["path",{"d":"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"}],["path",{"d":"M21 3v5h-5"}],["path",{"d":"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"}],["path",{"d":"M8 16H3v5"}]], 'sun': [["circle",{"cx":"12","cy":"12","r":"4"}],["path",{"d":"M12 2v2"}],["path",{"d":"M12 20v2"}],["path",{"d":"m4.93 4.93 1.41 1.41"}],["path",{"d":"m17.66 17.66 1.41 1.41"}],["path",{"d":"M2 12h2"}],["path",{"d":"M20 12h2"}],["path",{"d":"m6.34 17.66-1.41 1.41"}],["path",{"d":"m19.07 4.93-1.41 1.41"}]], 'moon': [["path",{"d":"M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"}]], @@ -38,4 +41,5 @@ export const ICONS = { 'git-merge': [["circle",{"cx":"18","cy":"18","r":"3"}],["circle",{"cx":"6","cy":"6","r":"3"}],["path",{"d":"M6 21V9a9 9 0 0 0 9 9"}]], } satisfies Record; -export type IconName = keyof typeof ICONS; +export type IconName = keyof typeof DATA; +export const ICONS: Record = DATA; diff --git a/desktop/renderer/src/styles.css b/desktop/renderer/src/styles.css index 615c153..3093595 100644 --- a/desktop/renderer/src/styles.css +++ b/desktop/renderer/src/styles.css @@ -1,6 +1,5 @@ -/* Quorum Design System tokens (see styles/quorum/). Imported first: until the v0.12 restyle - replaces the rules below, the old variables that share a name (--space-7, --space-8, - --font-sans) still win, so the current look holds screen by screen. */ +/* Quorum Design System tokens (see styles/quorum/), then the old v0.11 rules while the + v0.12 restyle replaces them screen by screen, then the restyled screens (styles/app/). */ @import './styles/quorum/fonts.css'; @import './styles/quorum/colors.css'; @import './styles/quorum/typography.css'; @@ -9,6 +8,8 @@ @import './styles/quorum/motion.css'; @import './styles/quorum/layers.css'; @import './styles/quorum/base.css'; +@import './styles/app/controls.css'; +@import './styles/app/shell.css'; /* ===== pr-sweep design system ===== Utilitarian catalog-density look (think industrial-supply websites): Arial, @@ -39,10 +40,8 @@ --color-row-hover: #fff8f1; /* warm hover tint */ --color-selection: #f2f5ff; /* cool selection tint */ - --font-sans: Arial,"Helvetica Neue",Helvetica,"Liberation Sans",system-ui,sans-serif; --fs-caption:11px; --fs-dense:12px; --fs-base:13px; --fs-section:14px; --fs-h3:16px; --fs-h2:18px; --fs-h1:22px; --fw-normal:400; --fw-bold:700; --lh-tight:1.25; --lh-base:1.4; --lh-dense:1.3; - --space-1:2px; --space-2:4px; --space-3:6px; --space-4:8px; --space-5:12px; --space-6:16px; --space-7:24px; --space-8:32px; --radius:0px; --header-height:56px; --cell-padding:6px 8px; } @@ -158,24 +157,6 @@ td.why { font-size: var(--fs-caption); color: var(--color-text-secondary); line- } .wait-tag.wait-hot { color: var(--color-warning); border-color: var(--color-warning); font-weight: var(--fw-bold); } -/* auto-update pill in the header: passive while downloading, a button when ready */ -.update-pill { - font-size: var(--fs-caption); - color: var(--color-text-secondary); - border: 1px solid var(--color-border-strong); - background: var(--color-bg-panel); - border-radius: var(--radius); - padding: 2px var(--space-2); - white-space: nowrap; - font-variant-numeric: tabular-nums; -} -button.update-ready { - color: var(--color-success); - border-color: var(--color-success); - font-weight: var(--fw-bold); - cursor: pointer; -} - /* ===== buttons ===== */ button, .btn { font-family: var(--font-sans); @@ -224,47 +205,9 @@ input[type="number"] { text-align: right; } label { font-size: var(--fs-dense); color: var(--color-text-secondary); } -/* ===== layout shell ===== */ -.app { display: flex; flex-direction: column; min-height: 100vh; height: 100vh; } -.app-header { - height: var(--header-height); - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-6); - padding: 0 var(--space-6); - border-bottom: 2px solid var(--color-primary); - background: var(--color-bg); - flex: 0 0 auto; -} -.page-title { - font-size: var(--fs-h2); - font-weight: var(--fw-bold); - margin: 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.header-right { display: flex; align-items: center; gap: var(--space-6); flex: 0 0 auto; } -.header-right label { display: flex; align-items: center; gap: var(--space-3); } -.header-status { font-size: var(--fs-dense); color: var(--color-text-secondary); font-variant-numeric: tabular-nums; } - -.nav-link { color: var(--color-text); font-size: var(--fs-base); padding: var(--space-2) 0; border-bottom: 3px solid transparent; } -.nav-link:hover { text-decoration: none; color: var(--color-primary); } -.nav-link.active { font-weight: var(--fw-bold); color: var(--color-primary); border-bottom-color: var(--color-gold); } - -.main { flex: 1 1 auto; min-width: 0; padding: var(--space-6); overflow: auto; } - /* ===== status / error panels ===== */ .status { padding: var(--space-4); border: 1px solid var(--color-border); background: var(--color-bg-panel); font-size: var(--fs-dense); } .status.error { border-color: var(--color-error); color: var(--color-error); background: var(--color-bg); } -.error-banner { - padding: var(--space-3) var(--space-6); - border-bottom: 1px solid var(--color-error); - color: var(--color-error); - background: var(--color-bg); - font-size: var(--fs-dense); -} /* ===== KPI boxes ===== */ .kpi-row { display: flex; gap: var(--space-5); margin-bottom: var(--space-6); flex-wrap: wrap; } @@ -303,7 +246,7 @@ label { font-size: var(--fs-dense); color: var(--color-text-secondary); } .toolbar .spacer { flex: 1 1 auto; } .toolbar .search { min-width: 220px; } -.section { margin-bottom: var(--space-7); } +.section { margin-bottom: var(--space-8); } .section h2 .muted { font-weight: var(--fw-normal); font-size: var(--fs-base); } .empty-note { font-size: var(--fs-dense); color: var(--color-text-muted); margin: var(--space-3) 0; } @@ -357,10 +300,10 @@ tr.snoozed td:not(.next), tr.quiet td:not(.next) { opacity: 0.55; } background: var(--color-bg); border: 2px solid var(--color-border-strong); border-top: 4px solid var(--color-primary); - padding: var(--space-7); + padding: var(--space-8); width: 460px; } -.modal ol { margin: var(--space-5) 0; padding-left: var(--space-7); } +.modal ol { margin: var(--space-5) 0; padding-left: var(--space-8); } .modal li { margin-bottom: var(--space-3); } .modal code { background: var(--color-bg-panel); border: 1px solid var(--color-border); padding: 0 var(--space-2); font-size: var(--fs-dense); } .token-row { display: flex; gap: var(--space-4); margin-top: var(--space-5); } diff --git a/desktop/renderer/src/styles/app/controls.css b/desktop/renderer/src/styles/app/controls.css new file mode 100644 index 0000000..a7a2e8f --- /dev/null +++ b/desktop/renderer/src/styles/app/controls.css @@ -0,0 +1,133 @@ +/* Quorum's controls as plain classes on native elements: buttons, inputs, the + segmented switch, badges, status dots and banners. Tokens only (see + styles/quorum/); check-contrast lists every text/surface pair used here. */ + +.q-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + height: var(--density-control); + padding: 0 var(--space-5); + font: var(--text-body-strong); + font-size: 12px; + color: var(--text-1); + background: var(--bg-2); + border: var(--border-width) solid var(--border-2); + border-radius: var(--radius-2); + cursor: pointer; + white-space: nowrap; + transition: background var(--dur-fast) var(--ease-standard), border-color var(--dur-fast) var(--ease-standard); +} +.q-btn:hover:not(:disabled) { background: var(--bg-3); border-color: var(--border-3); } +.q-btn:active:not(:disabled) { transform: translateY(0.5px); } +.q-btn:disabled { color: var(--text-4); cursor: not-allowed; background: var(--bg-2); } +.q-btn:focus-visible { outline: none; box-shadow: var(--focus-ring); } + +.q-btn--primary { color: var(--text-inverse); background: var(--accent); border-color: var(--accent); } +.q-btn--primary:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); } +.q-btn--primary:disabled { color: var(--text-inverse); opacity: 0.55; background: var(--accent); } +.q-btn--ghost { background: transparent; border-color: transparent; color: var(--text-2); } +.q-btn--ghost:hover:not(:disabled) { background: var(--bg-3); border-color: transparent; color: var(--text-1); } +.q-btn--ghost:disabled { background: transparent; } +.q-btn--danger { color: var(--critical-text); background: transparent; border-color: var(--critical-border); } +.q-btn--danger:hover:not(:disabled) { background: var(--critical-subtle); border-color: var(--critical-border); } +.q-btn--sm { height: 24px; padding: 0 var(--space-4); } +.q-btn--icon { width: var(--density-control); padding: 0; } + +.q-input { + height: var(--density-control); + padding: 0 var(--space-4); + font: var(--text-body); + color: var(--text-1); + background: var(--bg-2); + border: var(--border-width) solid var(--border-2); + border-radius: var(--radius-2); +} +.q-input::placeholder { color: var(--text-3); } +.q-input:hover:not(:disabled) { border-color: var(--border-3); } +.q-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } +.q-input:disabled { color: var(--text-3); } +.q-input--mono { font: var(--text-data); } +/* The native date picker's icon follows the theme's color scheme. */ +.q-input[type="date"]::-webkit-calendar-picker-indicator { opacity: 0.7; cursor: pointer; } + +.q-label { + font: var(--text-label); + letter-spacing: var(--tracking-label); + text-transform: uppercase; + color: var(--text-2); +} + +/* Segmented switch: one choice of a few, like Sprints / Custom. */ +.q-seg { + display: inline-flex; + padding: 2px; + gap: 2px; + background: var(--bg-1); + border: var(--border-width) solid var(--border-2); + border-radius: var(--radius-2); +} +.q-seg__item { + height: 22px; + padding: 0 var(--space-4); + font: var(--text-compact); + font-weight: var(--weight-semibold); + color: var(--text-2); + background: transparent; + border: 0; + border-radius: var(--radius-1); + cursor: pointer; +} +.q-seg__item:hover:not(:disabled) { color: var(--text-1); background: var(--bg-3); } +.q-seg__item[aria-checked="true"] { color: var(--text-1); background: var(--bg-4); } +.q-seg__item:focus-visible { outline: none; box-shadow: var(--focus-ring); } +.q-seg__item:disabled { cursor: not-allowed; } + +/* Status: a dot's color and shape, always next to words. */ +.q-dot { + display: inline-block; + width: 7px; + height: 7px; + flex: 0 0 auto; + border-radius: var(--radius-round); + background: currentColor; +} +.q-dot--healthy { color: var(--healthy); } +.q-dot--warning { color: var(--warning); } +.q-dot--critical { color: var(--critical); } +.q-dot--running { color: var(--running); } +.q-dot--info { color: var(--info); } +.q-dot--pulse { animation: q-pulse var(--pulse-duration) var(--ease-standard) infinite; } + +.q-badge { + display: inline-flex; + align-items: center; + gap: var(--space-2); + height: 22px; + padding: 0 var(--space-4); + font: var(--text-compact); + font-weight: var(--weight-semibold); + border: var(--border-width) solid transparent; + border-radius: var(--radius-2); + white-space: nowrap; +} +.q-badge--info { color: var(--info-text); background: var(--info-subtle); border-color: var(--info-border); } + +.q-banner { + display: flex; + align-items: flex-start; + gap: var(--space-4); + margin: var(--space-6) var(--space-8) 0; + padding: var(--space-5) var(--space-6); + border: var(--border-width) solid transparent; + border-radius: var(--radius-3); +} +.q-banner p { margin: 0; } +.q-banner--critical { color: var(--critical-text); background: var(--critical-subtle); border-color: var(--critical-border); } +.q-banner__title { font: var(--text-body-strong); } +.q-banner__desc { font: var(--text-data); color: var(--text-1); margin-top: var(--space-1); } +.q-banner__meta { font: var(--text-compact); color: var(--text-2); margin-top: var(--space-1); } +.q-banner .q-icon { flex: 0 0 auto; margin-top: 1px; } + +.q-icon { display: inline-flex; line-height: 0; } diff --git a/desktop/renderer/src/styles/app/shell.css b/desktop/renderer/src/styles/app/shell.css new file mode 100644 index 0000000..31dfcce --- /dev/null +++ b/desktop/renderer/src/styles/app/shell.css @@ -0,0 +1,91 @@ +/* The app shell: Quorum's top bar (tabs, freshness, period, refresh, theme), + the loading bar under it, and the scrolling main pane. */ + +.app { + display: flex; + flex-direction: column; + height: 100vh; + background: var(--surface-app); + color: var(--text-body); + font: var(--text-body); +} + +.topbar { + position: relative; + display: flex; + align-items: center; + gap: var(--space-6); + flex: 0 0 auto; + height: var(--topbar-height); + padding: 0 var(--space-6); + background: var(--surface-panel); + border-bottom: var(--border-width) solid var(--border-hairline); +} +.topbar .spacer { flex: 1 1 auto; } +.topbar__title { + margin: 0; + font: var(--text-section-title); + letter-spacing: var(--tracking-tight); + color: var(--text-1); + white-space: nowrap; +} +.topbar__profile { display: inline-flex; align-items: center; gap: var(--space-3); } + +.q-tabs { display: flex; align-self: stretch; gap: var(--space-5); } +.q-tab { + display: inline-flex; + align-items: center; + padding: 0 var(--space-1); + font: var(--text-body-strong); + color: var(--text-2); + border-bottom: 2px solid transparent; + text-decoration: none; +} +.q-tab:hover { color: var(--text-1); text-decoration: none; } +.q-tab.active { color: var(--text-1); border-bottom-color: var(--accent); } +.q-tab:focus-visible { outline: none; box-shadow: var(--focus-ring); } + +.q-fresh { + display: inline-flex; + align-items: center; + gap: var(--space-3); + font: var(--text-compact); + color: var(--text-2); + white-space: nowrap; +} +.q-fresh__age { font: var(--text-data); color: var(--text-2); } + +.period { display: inline-flex; align-items: center; gap: var(--space-2); } +.period__label { + min-width: 190px; + font: var(--text-data-value); + color: var(--text-1); + text-align: center; + white-space: nowrap; +} +.period__date { display: inline-flex; align-items: center; gap: var(--space-3); font: var(--text-compact); color: var(--text-2); } +.period__setup { font: var(--text-compact); margin-left: var(--space-3); white-space: nowrap; } + +/* Quorum's loading sweep: a thin bar across the top bar's bottom edge. */ +.loadbar { + position: absolute; + left: 0; + right: 0; + bottom: -1px; + height: 2px; + overflow: hidden; +} +.loadbar::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + width: 33%; + background: var(--accent); + animation: q-sweep 1.2s var(--ease-standard) infinite; +} +@media (prefers-reduced-motion: reduce) { + .loadbar::after { width: 100%; opacity: 0.6; animation: none; } +} + +.main { flex: 1 1 auto; min-width: 0; padding: var(--space-6) var(--space-8); overflow: auto; } From 257254002b5b1c17cf3d8284f8d0bda6993ffa08 Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Tue, 29 Sep 2026 13:21:02 -0500 Subject: [PATCH 08/30] fix(ui): color body text with text-1, since Quorum's --text-body name is taken by a font --- desktop/renderer/src/styles/app/shell.css | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/desktop/renderer/src/styles/app/shell.css b/desktop/renderer/src/styles/app/shell.css index 31dfcce..421b7c8 100644 --- a/desktop/renderer/src/styles/app/shell.css +++ b/desktop/renderer/src/styles/app/shell.css @@ -1,12 +1,17 @@ /* The app shell: Quorum's top bar (tabs, freshness, period, refresh, theme), the loading bar under it, and the scrolling main pane. */ +/* Quorum's base.css colors the body with --text-body, but typography.css + defines --text-body too (as a font), and the later one wins, so the color + never applies. Name the color token directly. */ +body { color: var(--text-1); } + .app { display: flex; flex-direction: column; height: 100vh; background: var(--surface-app); - color: var(--text-body); + color: var(--text-1); font: var(--text-body); } From 7f731cfe6325704c5d46279967058850286bcb9c Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Tue, 29 Sep 2026 13:21:03 -0500 Subject: [PATCH 09/30] feat(ui): Quorum board with a health strip, filter chips, dense tables, CI status and empty and loading states --- desktop/renderer/scripts/check-contrast.mjs | 6 + desktop/renderer/src/app/board.store.ts | 8 + .../renderer/src/app/pages/board.component.ts | 668 +++++++++++------- .../src/app/ui/ci-status.component.ts | 34 + desktop/renderer/src/styles.css | 150 +--- desktop/renderer/src/styles/app/board.css | 103 +++ desktop/renderer/src/styles/app/controls.css | 57 ++ desktop/renderer/src/styles/app/shell.css | 5 +- desktop/renderer/src/styles/app/table.css | 92 +++ 9 files changed, 717 insertions(+), 406 deletions(-) create mode 100644 desktop/renderer/src/app/ui/ci-status.component.ts create mode 100644 desktop/renderer/src/styles/app/board.css create mode 100644 desktop/renderer/src/styles/app/table.css diff --git a/desktop/renderer/scripts/check-contrast.mjs b/desktop/renderer/scripts/check-contrast.mjs index 85215e7..9b15419 100644 --- a/desktop/renderer/scripts/check-contrast.mjs +++ b/desktop/renderer/scripts/check-contrast.mjs @@ -28,6 +28,12 @@ const PAIRS = [ ['text-1', `${st}-subtle over bg-0`, 4.5], ['text-2', `${st}-subtle over bg-0`, 4.5], ]), + // Board: tables and the health strip are bg-1 panels whose rows hover to bg-2. + ...['healthy', 'warning', 'critical', 'running'].map((st) => [`${st}-text`, 'bg-1', 4.5]), // reasons, CI, Δ, stale ages + ['warning-text', 'warning-subtle over bg-1', 4.5], // a review waiting too long + ['warning-text', 'bg-0', 4.5], // the sprint risk line + ['accent-text', 'bg-1', 4.5], // a focused PR title + ['text-1', 'accent-subtle over bg-0', 4.5], // a pressed filter chip ['accent', 'bg-0', 3], // focus ring ['accent', 'bg-2', 3], ...['healthy', 'warning', 'critical', 'running'].flatMap((st) => [[st, 'bg-1', 3], [st, 'bg-2', 3]]), // status dots diff --git a/desktop/renderer/src/app/board.store.ts b/desktop/renderer/src/app/board.store.ts index 53b7e9b..83b9c30 100644 --- a/desktop/renderer/src/app/board.store.ts +++ b/desktop/renderer/src/app/board.store.ts @@ -100,6 +100,9 @@ export class BoardStore { this.applyFilters(this.result()?.merged ?? []).sort(byNewest((r) => r.mergedAt ?? r.updatedAt)), ); + /** An author chip or the text filter is narrowing the board. */ + readonly filtering = computed(() => this.authorFilter().size > 0 || this.search().trim() !== ''); + readonly openCount = computed( () => this.needsReview().length + this.changesRequested().length + this.approved().length, ); @@ -233,6 +236,11 @@ export class BoardStore { this.authorFilter.set(next); } + clearFilters(): void { + this.authorFilter.set(new Set()); + this.search.set(''); + } + async saveToken(token: string): Promise { const status = await this.api.setToken(token); this.auth.set(status); diff --git a/desktop/renderer/src/app/pages/board.component.ts b/desktop/renderer/src/app/pages/board.component.ts index 21fa1f5..f9bfdc3 100644 --- a/desktop/renderer/src/app/pages/board.component.ts +++ b/desktop/renderer/src/app/pages/board.component.ts @@ -1,6 +1,9 @@ +import { NgTemplateOutlet } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; import { BoardStore } from '../board.store'; import { Attention, AttentionReason, PrRow, SprintRisk } from '../models'; +import { CiStatusComponent } from '../ui/ci-status.component'; +import { IconComponent } from '../ui/icon.component'; const REASON_LABELS: Record = { CI_FAILING: 'CI failing', @@ -15,312 +18,420 @@ const REASON_LABELS: Record = { }; interface BoardSection { + id: string; title: string; rows: PrRow[]; /** Merged rows date from merge time and don't have outstanding reviewers or CI. */ merged: boolean; - /** The "waiting on my review" section gets review-wait badges. */ + /** The "waiting on my review" section gets review-wait tags, and author chips don't apply to it. */ queue?: boolean; emptyNote: string; } +/** One cell of the health strip: a count, and a dot and a note that say whether it's fine. */ +interface HealthCell { + label: string; + count: number | null; + tone: 'healthy' | 'warning' | 'info' | null; + note: string; +} + +const SKELETON_ROWS = [1, 2, 3]; +const SWEEP_SKELETON_ROWS = [1, 2, 3, 4, 5]; + /** - * The dashboard: KPI counts, a filter toolbar (author toggles + free text), - * the Sweep (open PRs the attention engine flagged, each with its reason and - * next step), then one dense table per status — needs review / changes - * requested / approved / merged this sprint. All slicing is client-side over - * the store's fetched result; clicking a status row opens the PR in the - * default browser. + * The dashboard: a health strip of counts, the filter bar (author chips, + * drafts, free text), the Sweep (open PRs the attention engine flagged, each + * with its reason and next step), then one dense table per status: my queue, + * needs review, changes requested, approved, merged. All slicing is + * client-side over the store's fetched result; a status row opens its PR in + * the default browser. */ @Component({ selector: 'app-board', standalone: true, + imports: [NgTemplateOutlet, CiStatusComponent, IconComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -
-
-
My queue
-
{{ store.queue().length }}
-
-
-
Needs review
-
{{ store.needsReview().length }}
-
-
-
Changes requested
-
{{ store.changesRequested().length }}
-
-
-
Approved
-
{{ store.approved().length }}
-
-
-
Merged in range
-
{{ store.merged().length }}
-
-
- -
- - @for (login of authors(); track login) { - +
+ @for (cell of health(); track cell.label) { +
+ {{ cell.label }} + {{ cell.count ?? '–' }} + + @if (cell.tone) { + + } + {{ cell.note }} + +
} +
+ +
+
+ Authors + @for (login of authors(); track login) { + + } +
- - + @if (store.filtering()) { + + } +
-
-
-

Sweep ({{ store.sweep().length }})

+
+
+

+ Sweep + @if (store.result()) { + {{ store.sweep().length }} + } +

@if (store.sprintRisk(); as risk) { -

{{ sprintLine(risk) }}

- } - @if (store.quiet().length > 0) { - +

+ + {{ sprintLine(risk) }} +

} - @if (store.snoozed().length > 0) { - - } -
- @if (sweepRows().length > 0) { - - - - - - - - - - - - - - @for (pr of sweepRows(); track pr.url) { - - - - - - - - - } - -
PRCITitleWhyAuthorNext step
{{ pr.repo }}#{{ pr.number }} - @if (pr.ci; as ci) { - - } - - - @if (pr.isDraft) { - draft - } - - {{ label(pr.attention[0]) }} - @if (pr.attention[0].since; as since) { - · {{ age(since) }} - } - @for (other of pr.attention.slice(1); track other.reason) { - {{ label(other) }} - } - {{ pr.author }} - - @if (pr.quiet) { - - } @else if (store.isSnoozed(pr)) { - - } @else { - - } -
- } @else { -

- Nothing needs attention. - @if (hiddenNote(); as note) { - ({{ note }}) + + @if (store.quiet().length > 0) { + } -

- } -
- - @for (section of sections(); track section.title) { -
-

{{ section.title }} ({{ section.rows.length }})

- @if (section.rows.length > 0) { - + @if (store.snoozed().length > 0) { + + } + + + @if (!store.result()) { + + } @else if (sweepRows().length > 0) { +
+
- - @if (!section.merged) { - - } - - - - - @if (!section.merged) { - - } - + + + + + + - @for (pr of section.rows; track pr.url) { - - - @if (!section.merged) { - + + + - } + + - - - + - @if (!section.merged) { - - } - }
PRCITitleAuthorCommentsΔAwaiting{{ section.merged ? 'Merged' : 'Updated' }}PRCITitleWhyAuthorNext step
{{ pr.repo }}#{{ pr.number }} - @if (pr.ci; as ci) { - + + @for (pr of sweepRows(); track pr.url) { +
{{ pr.repo }}#{{ pr.number }} + + + @if (pr.isDraft) { + draft } - - {{ pr.title }} - @if (pr.isDraft) { - draft - } - @if (section.queue && waitingDays(pr) >= 1) { - - waiting {{ waitingDays(pr) }}d - - } + + + {{ label(pr.attention[0]) }} + @if (pr.attention[0].since; as since) { + {{ age(since) }} + } + @for (other of pr.attention.slice(1); track other.reason) { + {{ label(other) }} + } + @if (pr.quiet) { + quiet + } @else if (store.isSnoozed(pr)) { + snoozed + } + {{ pr.author }}{{ pr.comments || '' }} - +{{ pr.additions }} - −{{ pr.deletions }} + {{ pr.author }} + + + @if (pr.quiet) { + + } @else if (store.isSnoozed(pr)) { + + } @else { + + } + {{ pr.requestedReviewers.join(', ') }}{{ ago(pr) }}
+
+ } @else if (store.filtering()) { +
+ +

No PRs match these filters

+

Nothing flagged among the PRs these filters show.

+
+ } @else { +
+ +

Nothing needs attention

+ @if (hiddenNote(); as note) { +

{{ note }}

+ } +
+ } + + + @for (section of sections(); track section.id) { +
+
+

+ {{ section.title }} + @if (store.result()) { + {{ section.rows.length }} + } +

+
+ @if (!store.result()) { + + } @else if (section.rows.length > 0) { +
+ + + + + @if (!section.merged) { + + } + + + + + @if (!section.merged) { + + } + + + + + @for (pr of section.rows; track pr.url) { + + + @if (!section.merged) { + + } + + + + + @if (!section.merged) { + + } + @if (!section.merged && isStale(pr)) { + + } @else { + + } + + } + +
PRCITitleAuthorComments + + Lines changed + Awaiting{{ section.merged ? 'Merged' : 'Updated' }}
{{ pr.repo }}#{{ pr.number }} + + {{ pr.title }} + @if (pr.isDraft) { + draft + } + @if (section.queue && waitingDays(pr) >= 1) { + + waiting {{ waitingDays(pr) }}d + + } + + {{ pr.author }}{{ pr.comments || '' }} + +{{ pr.additions }} + −{{ pr.deletions }} + {{ pr.requestedReviewers.join(', ') }} + + {{ ago(pr) }} + {{ ago(pr) }}
+
} @else { -

{{ section.emptyNote }}

+

{{ filteredOut(section) ? 'No PRs match these filters.' : section.emptyNote }}

}
} + + +
+ Loading pull requests + @for (row of rows; track row) { + + } +
+
`, }) export class BoardComponent { readonly store = inject(BoardStore); + readonly skeletonRows = SKELETON_ROWS; + readonly sweepSkeleton = SWEEP_SKELETON_ROWS; - readonly sections = computed(() => [ - { - title: 'Waiting on my review', - rows: this.store.queue(), - merged: false, - queue: true, - emptyNote: 'Nothing waiting on you.', - }, - { - title: 'Needs review', - rows: this.store.needsReview(), - merged: false, - emptyNote: 'Nothing waiting on review.', - }, - { - title: 'Changes requested', - rows: this.store.changesRequested(), - merged: false, - emptyNote: 'None.', - }, - { - title: 'Approved — ready to merge', - rows: this.store.approved(), - merged: false, - emptyNote: 'None ready to merge.', - }, - { - title: 'Merged in range', - rows: this.store.merged(), - merged: true, - emptyNote: 'Nothing merged in this range yet.', - }, - ]); + readonly sections = computed(() => { + const period = this.store.period(); + return [ + { + id: 'queue-title', + title: 'Waiting on my review', + rows: this.store.queue(), + merged: false, + queue: true, + emptyNote: 'Nothing waiting on you.', + }, + { + id: 'review-title', + title: 'Needs review', + rows: this.store.needsReview(), + merged: false, + emptyNote: 'Nothing waiting on review.', + }, + { + id: 'changes-title', + title: 'Changes requested', + rows: this.store.changesRequested(), + merged: false, + emptyNote: 'Nothing sent back for changes.', + }, + { + id: 'approved-title', + title: 'Approved, ready to merge', + rows: this.store.approved(), + merged: false, + emptyNote: 'Nothing approved and waiting to merge.', + }, + { + id: 'merged-title', + title: period?.sprint ? `Merged in ${period.sprint.name}` : 'Merged in this range', + rows: this.store.merged(), + merged: true, + emptyNote: period?.sprint ? `Nothing merged in ${period.sprint.name} yet.` : 'Nothing merged in this range yet.', + }, + ]; + }); + + readonly health = computed(() => { + const labels = ['My queue', 'Needs review', 'Changes requested', 'Approved', 'Merged']; + if (!this.store.result()) return labels.map((label) => ({ label, count: null, tone: null, note: '' })); + const sprint = this.store.period()?.sprint; + return [ + this.queueCell(labels[0], this.store.queue()), + this.openCell(labels[1], this.store.needsReview(), 'Nothing waiting on review'), + this.openCell(labels[2], this.store.changesRequested(), 'Nothing sent back'), + this.openCell(labels[3], this.store.approved(), 'Nothing waiting to merge'), + { + label: labels[4], + count: this.store.merged().length, + tone: 'info', + note: sprint ? `In ${sprint.name}` : 'In this range', + }, + ]; + }); /** Active Sweep rows, then the snoozed and quiet ones when they're revealed. */ readonly sweepRows = computed(() => [ @@ -329,22 +440,29 @@ export class BoardComponent { ...(this.store.showQuiet() ? this.store.quiet() : []), ]); - /** "2 snoozed, 12 quiet" for the empty state; null when nothing is hidden. */ + /** "2 snoozed · 12 quiet" for the empty state; null when nothing is hidden. */ readonly hiddenNote = computed(() => { const parts = [ this.store.snoozed().length ? `${this.store.snoozed().length} snoozed` : '', this.store.quiet().length ? `${this.store.quiet().length} quiet` : '', ].filter(Boolean); - return parts.length ? parts.join(', ') : null; + return parts.length ? parts.join(' · ') : null; }); + readonly staleDays = computed(() => this.store.activeProfile()?.staleDays ?? 0); + authors(): string[] { return this.store.activeProfile()?.authors ?? []; } + /** The queue ignores author chips, so only the text filter can empty it. */ + filteredOut(section: BoardSection): boolean { + return section.queue ? this.store.search().trim() !== '' : this.store.filtering(); + } + /** Untouched longer than the configured threshold (0 = feature off). */ isStale(pr: PrRow): boolean { - const days = this.store.activeProfile()?.staleDays ?? 0; + const days = this.staleDays(); return days > 0 && Date.now() - Date.parse(pr.updatedAt) > days * 86_400_000; } @@ -356,7 +474,7 @@ export class BoardComponent { /** Waiting past the same threshold the stale flag uses (0 = never hot). */ isWaitHot(pr: PrRow): boolean { - const days = this.store.activeProfile()?.staleDays ?? 0; + const days = this.staleDays(); return days > 0 && this.waitingDays(pr) >= days; } @@ -392,4 +510,32 @@ export class BoardComponent { const h = Math.floor(min / 60); return h < 48 ? `${h}h ago` : `${Math.floor(h / 24)}d ago`; } + + /** Warn when anything waited past the stale threshold; otherwise say how old the oldest is. */ + private openCell(label: string, rows: PrRow[], none: string): HealthCell { + if (rows.length === 0) return { label, count: 0, tone: 'healthy', note: none }; + const days = this.staleDays(); + const stale = rows.filter((r) => this.isStale(r)).length; + if (stale > 0) return { label, count: rows.length, tone: 'warning', note: `${stale} untouched for ${days}+ days` }; + const oldest = rows.reduce((a, r) => (r.updatedAt < a.updatedAt ? r : a)); + return { + label, + count: rows.length, + tone: days > 0 ? 'healthy' : 'info', + note: `All active in the last ${this.age(oldest.updatedAt)}`, + }; + } + + private queueCell(label: string, rows: PrRow[]): HealthCell { + if (rows.length === 0) return { label, count: 0, tone: 'healthy', note: 'Nothing waiting on you' }; + const hot = rows.filter((r) => this.isWaitHot(r)).length; + if (hot > 0) return { label, count: rows.length, tone: 'warning', note: `${hot} waiting ${this.staleDays()}+ days` }; + const requested = rows.map((r) => r.reviewRequestedAt).filter((t): t is string => !!t).sort(); + return { + label, + count: rows.length, + tone: 'info', + note: requested.length ? `Longest wait ${this.age(requested[0])}` : 'Waiting on you', + }; + } } diff --git a/desktop/renderer/src/app/ui/ci-status.component.ts b/desktop/renderer/src/app/ui/ci-status.component.ts new file mode 100644 index 0000000..8a1242a --- /dev/null +++ b/desktop/renderer/src/app/ui/ci-status.component.ts @@ -0,0 +1,34 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { PrRow } from '../models'; + +/** + * The latest commit's check rollup as a dot and a word. No cell when a PR has + * no checks. Running pulses; under reduced motion the ring holds still. + */ +@Component({ + selector: 'q-ci', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @switch (state()) { + @case ('success') { + + Pass + + } + @case ('failure') { + + Fail + + } + @case ('pending') { + + Running + + } + } + `, +}) +export class CiStatusComponent { + readonly state = input(null); +} diff --git a/desktop/renderer/src/styles.css b/desktop/renderer/src/styles.css index 3093595..5876978 100644 --- a/desktop/renderer/src/styles.css +++ b/desktop/renderer/src/styles.css @@ -10,6 +10,8 @@ @import './styles/quorum/base.css'; @import './styles/app/controls.css'; @import './styles/app/shell.css'; +@import './styles/app/table.css'; +@import './styles/app/board.css'; /* ===== pr-sweep design system ===== Utilitarian catalog-density look (think industrial-supply websites): Arial, @@ -91,36 +93,12 @@ h3 { font-size: var(--fs-h3); font-weight: var(--fw-bold); margin: 0 0 var(--spa a { color: var(--color-link); text-decoration: none; } a:hover { text-decoration: underline; } -/* ===== tables ===== */ -table { border-collapse: collapse; width: 100%; } -th, td { - border: 1px solid var(--color-border); - padding: var(--cell-padding); - text-align: left; - vertical-align: middle; -} -th { - background: var(--color-table-header); - font-weight: var(--fw-bold); - font-size: var(--fs-dense); - white-space: nowrap; -} -td { font-size: var(--fs-dense); } -tbody tr { background: var(--color-surface); } -tbody tr:nth-child(even) { background: var(--color-table-stripe); } -tbody tr:hover { background: var(--color-row-hover); } -tbody tr.clickable { cursor: pointer; } -tbody tr.clickable:focus-visible { outline: 2px solid var(--color-link); outline-offset: -2px; } - -.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } -.pos { color: var(--color-success); } -.neg { color: var(--color-error); } +/* ===== settings profile table (until Settings is restyled) ===== */ +.settings table { border-collapse: collapse; width: 100%; } +.settings th, .settings td { border: 1px solid var(--color-border); padding: var(--cell-padding); text-align: left; vertical-align: middle; font-size: var(--fs-dense); } +.settings th { background: var(--color-table-header); font-weight: var(--fw-bold); white-space: nowrap; } .muted { color: var(--color-text-muted); } -.secondary { color: var(--color-text-secondary); } -td.pr-ref { white-space: nowrap; font-variant-numeric: tabular-nums; } -/* aging cue: an open PR untouched past the configured threshold */ -td.stale { color: var(--color-warning); font-weight: var(--fw-bold); } .draft-tag { font-size: var(--fs-caption); color: var(--color-text-secondary); @@ -130,33 +108,6 @@ td.stale { color: var(--color-warning); font-weight: var(--fw-bold); } margin-left: var(--space-2); white-space: nowrap; } -td.why { font-size: var(--fs-caption); color: var(--color-text-secondary); line-height: var(--lh-dense); } - -/* CI traffic light: latest commit's check rollup (absent cell = no checks) */ -.ci-col { width: 28px; text-align: center; } -.ci-dot { - display: inline-block; - width: 9px; - height: 9px; - border-radius: 50%; - vertical-align: middle; -} -.ci-success { background: var(--color-success); } -.ci-failure { background: var(--color-error); } -.ci-pending { background: var(--color-warning); } - -/* review-wait cue on queue rows: how long a PR has been waiting on you */ -.wait-tag { - font-size: var(--fs-caption); - color: var(--color-text-secondary); - border: 1px solid var(--color-border-strong); - background: var(--color-bg-panel); - padding: 0 var(--space-2); - margin-left: var(--space-2); - white-space: nowrap; -} -.wait-tag.wait-hot { color: var(--color-warning); border-color: var(--color-warning); font-weight: var(--fw-bold); } - /* ===== buttons ===== */ button, .btn { font-family: var(--font-sans); @@ -180,14 +131,6 @@ button:disabled { color: var(--color-text-muted); cursor: not-allowed; backgroun } .btn-primary:hover:not(:disabled) { opacity: 0.9; background: var(--color-primary); color: var(--color-primary-contrast); } -button.toggle { font-size: var(--fs-dense); padding: 2px 8px; } -button.toggle.on { - background: var(--color-selection); - border-color: var(--color-primary); - color: var(--color-text); - font-weight: var(--fw-bold); -} - /* ===== inputs / selects ===== */ input, select { font-family: var(--font-sans); @@ -209,87 +152,6 @@ label { font-size: var(--fs-dense); color: var(--color-text-secondary); } .status { padding: var(--space-4); border: 1px solid var(--color-border); background: var(--color-bg-panel); font-size: var(--fs-dense); } .status.error { border-color: var(--color-error); color: var(--color-error); background: var(--color-bg); } -/* ===== KPI boxes ===== */ -.kpi-row { display: flex; gap: var(--space-5); margin-bottom: var(--space-6); flex-wrap: wrap; } -.kpi { - flex: 1 1 0; - min-width: 150px; - border: 1px solid var(--color-border); - border-top: 3px solid var(--color-border-strong); - background: var(--color-surface); - padding: var(--space-5); -} -.kpi .kpi-label { font-size: var(--fs-caption); text-transform: uppercase; letter-spacing: 0.5px; color: var(--color-text-secondary); margin-bottom: var(--space-2); } -.kpi .kpi-value { font-size: var(--fs-h1); font-weight: var(--fw-bold); font-variant-numeric: tabular-nums; } -.kpi-queue { border-top-color: var(--color-gold); } -.kpi-queue .kpi-value { color: var(--color-primary); } -.kpi-review { border-top-color: var(--color-warning); } -.kpi-review .kpi-value { color: var(--color-warning); } -.kpi-changes { border-top-color: var(--color-error); } -.kpi-changes .kpi-value { color: var(--color-error); } -.kpi-approved { border-top-color: var(--color-success); } -.kpi-approved .kpi-value { color: var(--color-success); } -.kpi-merged { border-top-color: var(--color-info); } -.kpi-merged .kpi-value { color: var(--color-info); } - -/* ===== toolbar ===== */ -.toolbar { - display: flex; - align-items: center; - gap: var(--space-5); - flex-wrap: wrap; - padding: var(--space-4) var(--space-5); - background: var(--color-bg-panel); - border: 1px solid var(--color-border); - margin-bottom: var(--space-5); -} -.toolbar .spacer { flex: 1 1 auto; } -.toolbar .search { min-width: 220px; } - -.section { margin-bottom: var(--space-8); } -.section h2 .muted { font-weight: var(--fw-normal); font-size: var(--fs-base); } -.empty-note { font-size: var(--fs-dense); color: var(--color-text-muted); margin: var(--space-3) 0; } - -/* ===== Sweep: open PRs the attention engine flagged ===== */ -.sweep-head { display: flex; align-items: baseline; gap: var(--space-5); flex-wrap: wrap; } -.sprint-risk { font-size: var(--fs-dense); font-weight: var(--fw-bold); color: var(--color-warning); margin: 0 0 var(--space-4) 0; } -td.reason { line-height: var(--lh-dense); } -.reason-label { font-weight: var(--fw-bold); } -.tier-hot { color: var(--color-error); } -.tier-warm { color: var(--color-warning); } -.tier-cool { color: var(--color-text-secondary); } -.reason-age { color: var(--color-text-secondary); font-variant-numeric: tabular-nums; white-space: nowrap; } -.reason-chip { - font-size: var(--fs-caption); - color: var(--color-text-secondary); - border: 1px solid var(--color-border-strong); - background: var(--color-bg-panel); - padding: 0 var(--space-2); - margin-left: var(--space-2); - white-space: nowrap; -} -th.next, td.next { text-align: right; white-space: nowrap; } -td.next button { font-size: var(--fs-dense); padding: 2px 8px; } -td.next .snooze-btn { margin-left: var(--space-2); color: var(--color-text-secondary); } -tr.snoozed td:not(.next), tr.quiet td:not(.next) { opacity: 0.55; } -.quiet-toggle { margin-left: auto; } -.quiet-toggle + .snooze-toggle { margin-left: 0; } -.snooze-toggle { margin-left: auto; } -.link-button { - border: none; - background: transparent; - padding: 0; - font: inherit; - color: var(--color-link); - text-align: left; - cursor: pointer; -} -.link-button:hover { background: transparent; text-decoration: underline; } -.link-button:focus-visible { outline: 2px solid var(--color-link); outline-offset: 2px; } - -.meta { font-size: var(--fs-dense); color: var(--color-text-secondary); margin: var(--space-4) 0; } -.meta strong { color: var(--color-text); } - /* ===== modal (onboarding) ===== */ .modal-overlay { position: fixed; inset: 0; z-index: 200; diff --git a/desktop/renderer/src/styles/app/board.css b/desktop/renderer/src/styles/app/board.css new file mode 100644 index 0000000..8e8e524 --- /dev/null +++ b/desktop/renderer/src/styles/app/board.css @@ -0,0 +1,103 @@ +/* The board: health strip, filter bar, the Sweep and the status tables. */ + +.health { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + margin-bottom: var(--space-6); + background: var(--surface-panel); + border: var(--border-width) solid var(--border-hairline); + border-radius: var(--radius-3); +} +.health__cell { + display: flex; + flex-direction: column; + gap: var(--space-2); + min-width: 0; + padding: var(--space-5) var(--space-6); +} +.health__cell + .health__cell { border-left: var(--border-width) solid var(--border-hairline); } +.health__value { font: var(--text-metric); font-feature-settings: var(--tnum); color: var(--text-1); } +.health__note { + display: flex; + align-items: center; + gap: var(--space-3); + min-height: 17px; + font: var(--text-compact); + color: var(--text-2); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.filters { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-5); margin-bottom: var(--space-8); } +.filters__group { display: inline-flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); } +.filters__group > .q-label { margin-right: var(--space-3); } +.filters .q-search { margin-left: auto; } + +.board-section { margin-bottom: var(--space-9); } +.board-section__head { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-5); min-height: 28px; margin-bottom: var(--space-4); } +.board-section__title { + display: inline-flex; + align-items: baseline; + gap: var(--space-3); + margin: 0; + font: var(--text-section-title); + letter-spacing: var(--tracking-tight); + color: var(--text-1); +} +.board-section__count { font: var(--text-data); color: var(--text-2); } +.board-section__actions { display: inline-flex; gap: var(--space-3); margin-left: auto; } + +.sprint-risk { + display: inline-flex; + align-items: center; + gap: var(--space-2); + margin: 0; + font: var(--text-compact); + font-weight: var(--weight-semibold); + color: var(--warning-text); +} +.sprint-risk--ok { color: var(--text-2); } + +/* Titles take the width that's left and truncate; the other columns fit their content. */ +.q-table .col-title { width: 100%; max-width: 0; } +.title-cell { display: flex; align-items: center; gap: var(--space-3); min-width: 0; } +.pr-title, .pr-link { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.pr-link { + padding: 0; + font: inherit; + color: var(--text-1); + text-align: left; + background: transparent; + border: 0; + cursor: pointer; +} +.pr-link:hover, .pr-link:focus-visible { color: var(--accent-text); text-decoration: underline; text-underline-offset: 2px; } +.pr-link:focus-visible { box-shadow: var(--focus-ring); border-radius: var(--radius-1); } +tr.clickable:hover .pr-title, tr.clickable:focus-visible .pr-title { color: var(--accent-text); } + +.q-table .col-ref { color: var(--text-2); } +.q-table .col-author, .q-table .col-awaiting { color: var(--text-2); } +.q-table .col-awaiting { max-width: 200px; overflow: hidden; text-overflow: ellipsis; } +.q-table .age { color: var(--text-2); } +.q-table .is-stale { color: var(--warning-text); } +.q-table .is-stale .q-icon { vertical-align: -2px; margin-right: var(--space-1); } +.delta-add { color: var(--healthy-text); } +.delta-del { color: var(--critical-text); margin-left: var(--space-2); } + +.reason { display: inline-flex; align-items: center; gap: var(--space-3); } +.reason__label { font-weight: var(--weight-semibold); } +.reason__age { font: var(--text-data); color: var(--text-2); } +.reason--hot .reason__label { color: var(--critical-text); } +.reason--hot .q-dot { color: var(--critical); } +.reason--warm .reason__label { color: var(--warning-text); } +.reason--warm .q-dot { color: var(--warning); } +.reason--cool .reason__label { color: var(--text-2); } +.reason--cool .q-dot { color: var(--text-3); } + +/* Snoozed and quiet rows stay readable; a tag says why they're set apart. */ +.q-table tr.is-muted .pr-link, .q-table tr.is-muted .reason__label { color: var(--text-2); } +.q-table tr.is-muted .reason .q-dot { color: var(--text-3); } + +.q-table .col-next { text-align: right; } +.next-step { display: inline-flex; justify-content: flex-end; gap: var(--space-2); } diff --git a/desktop/renderer/src/styles/app/controls.css b/desktop/renderer/src/styles/app/controls.css index a7a2e8f..4f566a9 100644 --- a/desktop/renderer/src/styles/app/controls.css +++ b/desktop/renderer/src/styles/app/controls.css @@ -131,3 +131,60 @@ .q-banner .q-icon { flex: 0 0 auto; margin-top: 1px; } .q-icon { display: inline-flex; line-height: 0; } + +/* Chip: a toggle in a filter bar. Pressed chips show a check, not only a tint. */ +.q-chip { + display: inline-flex; + align-items: center; + gap: var(--space-2); + height: 24px; + padding: 0 var(--space-4); + font: var(--text-compact); + font-weight: var(--weight-medium); + color: var(--text-2); + background: transparent; + border: var(--border-width) solid var(--border-2); + border-radius: var(--radius-2); + cursor: pointer; + white-space: nowrap; + transition: background var(--dur-fast) var(--ease-standard), border-color var(--dur-fast) var(--ease-standard); +} +.q-chip:hover { color: var(--text-1); background: var(--bg-2); border-color: var(--border-3); } +.q-chip[aria-pressed="true"] { color: var(--text-1); background: var(--accent-subtle); border-color: var(--accent-border); } +.q-chip:focus-visible { outline: none; box-shadow: var(--focus-ring); } +.q-chip--mono { font: var(--text-data); } + +/* Tag: a small neutral fact next to a title (draft, quiet, a second reason). */ +.q-tag { + display: inline-flex; + align-items: center; + flex: 0 0 auto; + height: 18px; + padding: 0 var(--space-3); + font: var(--text-compact); + font-size: 11px; + line-height: 1; + color: var(--text-2); + background: var(--bg-3); + border: var(--border-width) solid var(--border-2); + border-radius: var(--radius-1); + white-space: nowrap; +} +.q-tag--warning { color: var(--warning-text); background: var(--warning-subtle); border-color: var(--warning-border); } + +/* Search: an input with its icon inside. */ +.q-search { position: relative; display: inline-flex; align-items: center; } +.q-search > .q-icon { position: absolute; left: var(--space-4); color: var(--text-3); pointer-events: none; } +.q-search > .q-input { width: 260px; padding-left: 28px; } + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} diff --git a/desktop/renderer/src/styles/app/shell.css b/desktop/renderer/src/styles/app/shell.css index 421b7c8..1197798 100644 --- a/desktop/renderer/src/styles/app/shell.css +++ b/desktop/renderer/src/styles/app/shell.css @@ -93,4 +93,7 @@ body { color: var(--text-1); } .loadbar::after { width: 100%; opacity: 0.6; animation: none; } } -.main { flex: 1 1 auto; min-width: 0; padding: var(--space-6) var(--space-8); overflow: auto; } +.main { flex: 1 1 auto; min-width: 0; padding: 0 var(--space-8) var(--space-6); overflow: auto; } +/* The top gap is content, not padding: Chromium sticks table headers below a + scroll container's top padding, and rows would show through above them. */ +.main::before { content: ''; display: block; height: var(--space-6); } diff --git a/desktop/renderer/src/styles/app/table.css b/desktop/renderer/src/styles/app/table.css new file mode 100644 index 0000000..4bb1af2 --- /dev/null +++ b/desktop/renderer/src/styles/app/table.css @@ -0,0 +1,92 @@ +/* Quorum's data table, empty states and loading skeleton. Density comes from + Quorum's data-density scopes on the table: status tables are dense (26px + rows), the Sweep compact (32px). */ + +.q-table-wrap { + background: var(--surface-panel); + border: var(--border-width) solid var(--border-hairline); + border-radius: var(--radius-3); + /* clip, not hidden: hidden would make this the scroll container and stop + the headers sticking to the top of the page as it scrolls. */ + overflow: clip; +} + +.q-table { width: 100%; border-collapse: separate; border-spacing: 0; font: var(--text-body); font-size: var(--density-font); } +.q-table th { + position: sticky; + top: 0; + z-index: var(--z-sticky); + height: var(--density-row); + padding: 0 var(--density-cell-x); + font: var(--text-label); + letter-spacing: var(--tracking-label); + text-transform: uppercase; + text-align: left; + color: var(--text-2); + background: var(--surface-panel); + box-shadow: inset 0 -1px 0 var(--border-default); + white-space: nowrap; +} +.q-table td { + height: var(--density-row); + padding: var(--density-cell-y) var(--density-cell-x); + color: var(--text-1); + border-top: var(--border-width) solid var(--border-hairline); + vertical-align: middle; + white-space: nowrap; +} +.q-table tbody tr:first-child td { border-top: 0; } +.q-table tbody tr:hover { background: var(--surface-card); } +.q-table tr.clickable { cursor: pointer; } +.q-table tr.clickable:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } +.q-table .num { text-align: right; } +.q-table .mono { font: var(--text-data); font-size: var(--density-font-data); font-feature-settings: var(--tnum); } + +/* CI: a dot and a word, no fill, so a column of passes stays quiet. */ +.q-ci { display: inline-flex; align-items: center; gap: var(--space-3); font: var(--text-compact); font-weight: var(--weight-medium); } +.q-ci--pass { color: var(--text-2); } +.q-ci--fail { color: var(--critical-text); } +.q-ci--running { color: var(--running-text); } + +.q-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-3); + margin: 0; + padding: var(--space-9) var(--space-6); + color: var(--text-2); + background: var(--surface-panel); + border: var(--border-width) solid var(--border-hairline); + border-radius: var(--radius-3); + text-align: center; +} +.q-empty p { margin: 0; } +.q-empty__title { font: var(--text-body-strong); color: var(--text-1); } +.q-empty__meta { font: var(--text-compact); color: var(--text-2); } +.q-empty--good > .q-icon { color: var(--healthy); } +.q-empty--inline { display: block; padding: var(--space-5) var(--space-6); font: var(--text-compact); text-align: left; } + +.skeleton__row { + display: flex; + align-items: center; + gap: var(--space-8); + height: var(--density-row); + padding: 0 var(--density-cell-x); + border-top: var(--border-width) solid var(--border-hairline); +} +.skeleton__row:first-of-type { border-top: 0; } +.skeleton__bar { + height: 8px; + border-radius: var(--radius-1); + background: linear-gradient(90deg, var(--bg-3) 25%, var(--bg-4) 50%, var(--bg-3) 75%); + background-size: 200% 100%; + animation: q-shimmer 1.6s linear infinite; +} +.skeleton__bar:nth-child(1) { width: 72px; } +.skeleton__bar:nth-child(2) { flex: 1 1 auto; max-width: 420px; } +.skeleton__bar:nth-child(3) { width: 120px; } +.skeleton__bar:nth-child(4) { width: 56px; margin-left: auto; } +@media (prefers-reduced-motion: reduce) { + .skeleton__bar { animation: none; } +} From da996d6abeee61afeade52ff24b32bdf18d7ab3f Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Tue, 29 Sep 2026 13:21:13 -0500 Subject: [PATCH 10/30] docs(roadmap): tick the v0.12 board --- ROADMAP.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5208e68..8196a1a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -306,8 +306,9 @@ on the `release/v0.12` branch. `sprints` and `period` on each profile with a migration, and a `period:resolve` IPC call. - [x] Shell: top bar with the sprint picker, freshness indicator, loading bar, tabs, theme toggle, update pill and error banner. -- [ ] Board: health strip, filter bar, tables with density, CI status badges, Sweep rows, empty - states and skeleton rows. +- [x] Board: health strip, filter bar, tables with density, CI status badges, Sweep rows, empty + states and skeleton rows. *CI reads as a word (Pass, Fail, Running) next to its dot. + Snoozed and quiet rows get a tag instead of being faded, so they keep AA contrast.* - [ ] Settings, with a Sprints section, and onboarding. The old stylesheet is deleted and the no-raw-colors rule switches on. - [ ] Voice pass over every string, a Design section in `AGENTS.md`, and README screenshots from From 85616950bfdb6219d196318b7c3e651b3adb900a Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Tue, 29 Sep 2026 13:29:59 -0500 Subject: [PATCH 11/30] feat(sprints): preview a schedule's sprints around today from the main process --- desktop/renderer/src/app/models.ts | 15 +++++++++++++ desktop/src/main/core/config.service.ts | 2 +- desktop/src/main/core/sprints.test.mjs | 29 +++++++++++++++++++++++-- desktop/src/main/core/sprints.ts | 18 ++++++++++++++- desktop/src/main/ipc.ts | 9 ++++++-- desktop/src/preload/preload.ts | 1 + desktop/src/shared/types.ts | 16 ++++++++++++++ 7 files changed, 84 insertions(+), 6 deletions(-) diff --git a/desktop/renderer/src/app/models.ts b/desktop/renderer/src/app/models.ts index ec7d6bc..fa2b5d3 100644 --- a/desktop/renderer/src/app/models.ts +++ b/desktop/renderer/src/app/models.ts @@ -27,6 +27,20 @@ export interface Sprint { end: string; } +/** The sprints around today for the schedule editor in Settings. */ +export interface SprintPreview { + sprints: PreviewSprint[]; + /** The sprint holding today, or null before the schedule starts. */ + current: number | null; +} + +/** A sprint with what the editor shows for it, worked out in main like every sprint date. */ +export interface PreviewSprint extends Sprint { + /** "Sep 28 – Oct 11", with the year when it isn't this one. */ + dates: string; + days: number; +} + export interface ResolvedPeriod { kind: 'sprint' | 'custom'; label: string; @@ -169,6 +183,7 @@ export interface PrSweepApi { fetchPrs(range: DateRange, mode?: 'full' | 'auto'): Promise; latestSweep(): Promise; resolvePeriod(): Promise; + previewSprints(schedule: SprintSchedule): Promise; syncTray(sync: { queue: PrRow[]; mine: PrRow[]; needsReviewCount: number; attentionCount: number }): Promise; openExternal(url: string): Promise; onUpdateState(cb: (state: UpdateState | null) => void): void; diff --git a/desktop/src/main/core/config.service.ts b/desktop/src/main/core/config.service.ts index 6cabb69..72dc979 100644 --- a/desktop/src/main/core/config.service.ts +++ b/desktop/src/main/core/config.service.ts @@ -113,7 +113,7 @@ function normalizeProfile(p: Record): Profile { } /** A schedule that can be computed from, or null. A broken one is dropped, never guessed at. */ -function normalizeSprints(v: unknown): SprintSchedule | null { +export function normalizeSprints(v: unknown): SprintSchedule | null { if (!v || typeof v !== 'object') return null; const s = v as Record; const first = (s['first'] ?? {}) as Record; diff --git a/desktop/src/main/core/sprints.test.mjs b/desktop/src/main/core/sprints.test.mjs index 114df66..02cc4dd 100644 --- a/desktop/src/main/core/sprints.test.mjs +++ b/desktop/src/main/core/sprints.test.mjs @@ -5,7 +5,7 @@ * Run after `npm run build:main`: node src/main/core/sprints.test.mjs */ import assert from 'node:assert'; -import { resolvePeriod, sprintAt, sprintOn } from '../../../dist/main/main/core/sprints.js'; +import { resolvePeriod, sprintAt, sprintOn, sprintsAround } from '../../../dist/main/main/core/sprints.js'; const schedule = (patch = {}) => ({ pattern: 'Sprint {n}', @@ -108,4 +108,29 @@ const TODAY = '2026-09-29'; assert.equal(p.label, 'Sprint 2 · Jan 4–17, 2027', 'the year shows when it isn’t this one'); } -console.log('sprints: schedule math, current sprint and period resolution pass'); +// --- the editor's preview: two back, three ahead, never before the first --- +{ + const p = sprintsAround(schedule({ first: { number: 20, start: '2026-07-20' } }), TODAY); + assert.equal(p.current, 25); + assert.deepEqual(p.sprints.map((s) => s.number), [23, 24, 25, 26, 27, 28]); + assert.equal(p.sprints[2].start, '2026-09-28'); + assert.equal(p.sprints[2].dates, 'Sep 28 – Oct 11'); + assert.equal(p.sprints[2].days, 14); +} +{ + const p = sprintsAround(schedule(), TODAY); + assert.deepEqual(p.sprints.map((s) => s.number), [24, 25, 26, 27, 28], 'clipped at the first sprint'); +} +{ + const p = sprintsAround(schedule({ first: { number: 1, start: '2026-10-05' } }), TODAY); + assert.strictEqual(p.current, null, 'not started yet'); + assert.deepEqual(p.sprints.map((s) => s.number), [1, 2, 3, 4], 'the first few'); +} +{ + const p = sprintsAround(schedule({ names: { 26: 'Launch' }, lengths: { 25: 7 } }), TODAY); + assert.equal(p.sprints[2].name, 'Launch', 'overrides show in the preview'); + assert.equal(p.sprints[2].start, '2026-10-05', 'and so do shifted dates'); + assert.equal(p.sprints[1].days, 7); +} + +console.log('sprints: schedule math, current sprint, period resolution and preview pass'); diff --git a/desktop/src/main/core/sprints.ts b/desktop/src/main/core/sprints.ts index 68b3fd8..65468e1 100644 --- a/desktop/src/main/core/sprints.ts +++ b/desktop/src/main/core/sprints.ts @@ -5,7 +5,7 @@ * days (yyyy-mm-dd), and a sprint's end is its last day. Pure: "today" comes * from the caller. */ -import { DateRange, Profile, ResolvedPeriod, Sprint, SprintSchedule } from '../../shared/types'; +import { DateRange, PreviewSprint, Profile, ResolvedPeriod, Sprint, SprintPreview, SprintSchedule } from '../../shared/types'; const DAY = 86_400_000; /** Guards the walks below against a runaway schedule. */ @@ -44,6 +44,22 @@ export function sprintOn(s: SprintSchedule, date: string): Sprint | null { return null; } +/** + * The sprints around today: `before` earlier ones, the current one and `after` + * later ones, never before the first. Before the schedule starts, its first few. + */ +export function sprintsAround(s: SprintSchedule, today: string, before = 2, after = 3): SprintPreview { + const current = sprintOn(s, today)?.number ?? null; + const middle = current ?? s.first.number; + const year = Number(today.slice(0, 4)); + const sprints: PreviewSprint[] = []; + for (let n = middle - before; n <= middle + after; n++) { + const sprint = sprintAt(s, n); + if (sprint) sprints.push({ ...sprint, dates: span(sprint.start, sprint.end, year), days: lengthOf(s, n) }); + } + return { sprints, current }; +} + /** What the board shows for a profile today: its sprint (current or pinned), or its custom range. */ export function resolvePeriod(profile: Profile, today: string): ResolvedPeriod { const s = profile.sprints; diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index cf5b038..cd8d139 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -9,9 +9,9 @@ import { dialog, ipcMain, shell } from 'electron'; import { readFileSync, writeFileSync } from 'fs'; import { annotate } from './core/attention'; -import { activeProfile, ConfigService } from './core/config.service'; +import { activeProfile, ConfigService, normalizeSprints } from './core/config.service'; import { Fixture } from './core/fixture'; -import { localDate, resolvePeriod } from './core/sprints'; +import { localDate, resolvePeriod, sprintsAround } from './core/sprints'; import { GithubService, SweepStats } from './core/github.service'; import { DEFAULT_OAUTH_CLIENT_ID } from './core/oauth.constants'; import { pollForToken, requestDeviceCode } from './core/oauth.service'; @@ -83,6 +83,11 @@ export function registerIpc(services: Services): void { }); // Fixture mode starts empty, so the first sweep's loading state is visible. ipcMain.handle('period:resolve', () => resolvePeriod(activeProfile(services.config.get()), localDate())); + // The schedule comes from the editor unsaved, so it gets the same check a saved one does. + ipcMain.handle('sprints:preview', (_e, schedule: unknown) => { + const s = normalizeSprints(schedule); + return s ? sprintsAround(s, localDate()) : { sprints: [], current: null }; + }); ipcMain.handle('prs:latest', () => (services.fixture ? null : services.snapshots.get())); ipcMain.handle('shell:open', (_e, url: string) => { diff --git a/desktop/src/preload/preload.ts b/desktop/src/preload/preload.ts index f6d5c7c..fa078b5 100644 --- a/desktop/src/preload/preload.ts +++ b/desktop/src/preload/preload.ts @@ -28,6 +28,7 @@ const api: PrSweepApi = { fetchPrs: (range, mode) => ipcRenderer.invoke('prs:fetch', range, mode), latestSweep: () => ipcRenderer.invoke('prs:latest'), resolvePeriod: () => ipcRenderer.invoke('period:resolve'), + previewSprints: (schedule) => ipcRenderer.invoke('sprints:preview', schedule), syncTray: (sync) => ipcRenderer.invoke('tray:sync', sync), openExternal: (url) => ipcRenderer.invoke('shell:open', url), exportProfiles: () => ipcRenderer.invoke('config:export'), diff --git a/desktop/src/shared/types.ts b/desktop/src/shared/types.ts index 7abfb05..ed6d5c9 100644 --- a/desktop/src/shared/types.ts +++ b/desktop/src/shared/types.ts @@ -43,6 +43,20 @@ export interface Sprint { end: string; } +/** The sprints around today for the schedule editor in Settings (see sprints:preview). */ +export interface SprintPreview { + sprints: PreviewSprint[]; + /** The sprint holding today, or null before the schedule starts. */ + current: number | null; +} + +/** A sprint with what the editor shows for it, worked out in main like every sprint date. */ +export interface PreviewSprint extends Sprint { + /** "Sep 28 – Oct 11", with the year when it isn't this one. */ + dates: string; + days: number; +} + /** A profile's period resolved against today by the main process (see period:resolve). */ export interface ResolvedPeriod { kind: 'sprint' | 'custom'; @@ -234,6 +248,8 @@ export interface PrSweepApi { latestSweep(): Promise; /** The active profile's period (sprint or custom range) resolved against today. */ resolvePeriod(): Promise; + /** A schedule's sprints around today, for previewing edits before (and after) they're saved. */ + previewSprints(schedule: SprintSchedule): Promise; /** * Push the latest sweep's tray-relevant slices: the review queue (counts + * review-request toasts), the viewer's own open PRs (approval / changes- From bf0e6a0be98a17c1a311868458fb8f12e31a59df Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Tue, 29 Sep 2026 13:29:59 -0500 Subject: [PATCH 12/30] fix(ui): show the board's loading skeleton only while a sweep is loading --- desktop/renderer/src/app/pages/board.component.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/desktop/renderer/src/app/pages/board.component.ts b/desktop/renderer/src/app/pages/board.component.ts index f9bfdc3..e955c19 100644 --- a/desktop/renderer/src/app/pages/board.component.ts +++ b/desktop/renderer/src/app/pages/board.component.ts @@ -150,7 +150,15 @@ const SWEEP_SKELETON_ROWS = [1, 2, 3, 4, 5]; @if (!store.result()) { - + @if (store.loading()) { + + } @else { +
+ +

No pull requests yet

+

Refresh to sweep this period.

+
+ } } @else if (sweepRows().length > 0) {
@@ -258,7 +266,8 @@ const SWEEP_SKELETON_ROWS = [1, 2, 3, 4, 5]; } - @for (section of sections(); track section.id) { + + @for (section of store.result() || store.loading() ? sections() : []; track section.id) {

From 962747ea7471d8a093da1f2774f08a0de81c908e Mon Sep 17 00:00:00 2001 From: Hazel Granados Date: Tue, 29 Sep 2026 13:30:09 -0500 Subject: [PATCH 13/30] feat(ui): Quorum settings with a sprint schedule editor, and the onboarding dialog --- desktop/e2e/screenshot.mjs | 7 + desktop/renderer/scripts/check-contrast.mjs | 5 +- desktop/renderer/src/app/board.store.ts | 17 + .../renderer/src/app/onboarding.component.ts | 63 +- .../src/app/pages/settings.component.ts | 544 ++++++++++++++---- desktop/renderer/src/styles.css | 207 +------ desktop/renderer/src/styles/app/settings.css | 156 +++++ 7 files changed, 659 insertions(+), 340 deletions(-) create mode 100644 desktop/renderer/src/styles/app/settings.css diff --git a/desktop/e2e/screenshot.mjs b/desktop/e2e/screenshot.mjs index 1165808..09b8adf 100644 --- a/desktop/e2e/screenshot.mjs +++ b/desktop/e2e/screenshot.mjs @@ -103,6 +103,10 @@ const STATES = { async calm(win) { await boardReady(win); await shoot(win, 'calm'); + // No schedule yet: the top bar's link opens Settings on a sprint schedule to set up. + await win.getByRole('link', { name: 'Set up sprints' }).click(); + await win.locator('#sprints tbody tr').first().waitFor(); + await shoot(win, 'settings-setup'); }, async empty(win) { await boardReady(win); @@ -118,6 +122,9 @@ const STATES = { await win.getByRole('dialog').waitFor(); await win.waitForTimeout(500); await shoot(win, 'onboarding'); + await win.getByText('use a personal access token').click(); + await win.locator('input[type="password"]').waitFor(); + await shoot(win, 'onboarding-token'); }, async update(win) { await win.getByText(/0\.12\.1/).first().waitFor(); diff --git a/desktop/renderer/scripts/check-contrast.mjs b/desktop/renderer/scripts/check-contrast.mjs index 9b15419..aa4f1b0 100644 --- a/desktop/renderer/scripts/check-contrast.mjs +++ b/desktop/renderer/scripts/check-contrast.mjs @@ -33,7 +33,10 @@ const PAIRS = [ ['warning-text', 'warning-subtle over bg-1', 4.5], // a review waiting too long ['warning-text', 'bg-0', 4.5], // the sprint risk line ['accent-text', 'bg-1', 4.5], // a focused PR title - ['text-1', 'accent-subtle over bg-0', 4.5], // a pressed filter chip + // Settings: bg-1 cards holding bg-0 tables; the onboarding dialog is bg-2. + ['text-1', 'accent-subtle over bg-0', 4.5], // a pressed filter chip, the "active" and "current" tags + ['critical-text', 'critical-subtle over bg-1', 4.5], // a danger button, hovered + ['text-1', 'bg-inset', 4.5], // the device sign-in code ['accent', 'bg-0', 3], // focus ring ['accent', 'bg-2', 3], ...['healthy', 'warning', 'critical', 'running'].flatMap((st) => [[st, 'bg-1', 3], [st, 'bg-2', 3]]), // status dots diff --git a/desktop/renderer/src/app/board.store.ts b/desktop/renderer/src/app/board.store.ts index 83b9c30..8182b56 100644 --- a/desktop/renderer/src/app/board.store.ts +++ b/desktop/renderer/src/app/board.store.ts @@ -7,6 +7,8 @@ import { Profile, ResolvedPeriod, ProfilePatch, + SprintPreview, + SprintSchedule, SweepConfig, SweepConfigPatch, SweepResult, @@ -226,6 +228,21 @@ export class BoardStore { void this.refresh(); } + /** + * Save the active profile's sprint schedule and sweep what it now points at. + * A new schedule opens on the current sprint; clearing one goes back to the + * custom range; an edit keeps whichever sprint is showing. + */ + setSchedule(sprints: SprintSchedule | null): void { + const had = !!this.activeProfile()?.sprints; + this.patchProfile(sprints ? (had ? { sprints } : { sprints, period: 'current' }) : { sprints: null, period: 'custom' }); + void this.refresh(); + } + + previewSprints(schedule: SprintSchedule): Promise { + return this.api.previewSprints(schedule); + } + private async loadPeriod(): Promise { this.period.set(await this.api.resolvePeriod()); } diff --git a/desktop/renderer/src/app/onboarding.component.ts b/desktop/renderer/src/app/onboarding.component.ts index e9bd571..1a71096 100644 --- a/desktop/renderer/src/app/onboarding.component.ts +++ b/desktop/renderer/src/app/onboarding.component.ts @@ -1,5 +1,6 @@ import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; import { BoardStore } from './board.store'; +import { IconComponent } from './ui/icon.component'; /** * First-run overlay: shown until an org is configured and a GitHub token is @@ -12,19 +13,24 @@ import { BoardStore } from './board.store'; selector: 'app-onboarding', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, + imports: [IconComponent], template: ` @if (store.needsToken()) { -

- - - - - @for (p of store.profiles(); track p.id) { +
+
+

Profiles

+

A profile is a saved org, team and period. Switch between them from the top bar.

+
+
+
NameOrg
+ - - - - + + + - } - -
- @if (editingId() === p.id) { - - } @else { - {{ p.name }} - @if (p.id === store.activeProfile()?.id) { active } - } - {{ p.org || '—' }} - @if (p.id !== store.activeProfile()?.id) { - use - } - - rename - @if (store.profiles().length > 1) { - · delete - } - NameOrgActions
-
- - + + + @for (p of store.profiles(); track p.id) { + + + @if (editingId() === p.id) { + + } @else { + + {{ p.name }} + @if (p.id === store.activeProfile()?.id) { + active + } + + } + + {{ p.org || '–' }} + + + @if (p.id !== store.activeProfile()?.id) { + + } + + @if (store.profiles().length > 1) { + + } + + + + } + + +
+
+ + - - + +
-
-

Active profile — {{ store.activeProfile()?.name }}

- -

- Team members — PRs by these logins show on the board. Leave empty to see the whole org. -

-
    - @for (login of store.activeProfile()?.authors ?? []; track login) { -
  • - {{ login }} - -
  • - } -
-
- - -
-