diff --git a/README.md b/README.md index 77f0ce6..179a515 100644 --- a/README.md +++ b/README.md @@ -85,9 +85,9 @@ Regenerate with `pnpm screenshots` (UI + API running; default password `workbenc **Intent:** Keep the workspace sealed when you walk away. The unlock gate is the trust boundary for local data. -**How it works:** On first run you create a workspace password and choose an empty vault or a seeded demo org. Later sessions decrypt the vault with that password. **Lock & encrypt** re-seals SQLite and files so a stolen laptop disk is not a readable review archive. +**How it works:** On first run you create a workspace password and choose an empty vault or a seeded demo org. Later sessions decrypt the vault with that password. **Lock & encrypt** re-seals SQLite and files so a stolen laptop disk is not a readable review archive. To wipe and start over, use **Erase workspace** on Unlock or **Delete workspace** in Settings (password + type `DELETE`). -![Unlock workbench](./docs/screenshots/01-unlock.png) +![Unlock / create workbench](./docs/screenshots/01-unlock.png) ### Home (command center) @@ -179,6 +179,8 @@ Regenerate with `pnpm screenshots` (UI + API running; default password `workbenc **How it works:** Pick last 7 / 14 / 30 days or a custom range. See per-person achievement volume, Jira / Bitbucket throughput, feedback themes, open concerns, and evidence freshness (in-window vs older dossier items, plus stale-only cites on manager drafts). +![Sprint pulse](./docs/screenshots/20-sprint-pulse.png) + ### Backfill **Intent:** Unblock writing when dossiers are still empty after you first adopt the tool. @@ -199,7 +201,7 @@ Regenerate with `pnpm screenshots` (UI + API running; default password `workbenc **Intent:** Configure providers, privacy gates, appearance, and vault maintenance in one place. -**How it works:** Set AI provider and models (Anthropic, OpenAI, Cursor gateway, Ollama), toggle egress for private notes / meetings / document extracts, manage Jira and Bitbucket credentials, run encrypted backup / restore, verify the activity log, and choose appearance (system / dark / light plus Teal, Ocean, Forest, or Slate accent). +**How it works:** Set AI provider and models (Anthropic, OpenAI, Cursor gateway, Ollama), toggle egress for private notes / meetings / document extracts, manage Jira / Bitbucket / GitHub / Linear credentials, run encrypted backup / restore, verify the activity log, and choose appearance (system / dark / light plus Teal, Ocean, Forest, or Slate accent). A sticky **On this page** nav jumps between sections. **Danger zone** permanently erases the local vault so you can create a new empty or demo workspace. ![Settings](./docs/screenshots/14-settings.png) @@ -218,8 +220,9 @@ Regenerate with `pnpm screenshots` (UI + API running; default password `workbenc - **Promotions** against next-role expectations - **Backfill** wizard plus MCP / Jira / Bitbucket paths for thin dossiers - **AI**: digests, section draft, peer synthesize, chat, with citation allowlist -- **Desktop** packaging (Tauri) with CI installers on `main` -- **Dark / light** theme preference +- **Desktop** packaging (Tauri); installers via Actions **Run workflow** (not on every merge) +- **Dark / light** theme preference plus accent colors +- **Delete / recreate workspace** from Unlock or Settings ## Docs diff --git a/docs/screenshots/02-home.png b/docs/screenshots/02-home.png index 7eecb04..c3cce65 100644 Binary files a/docs/screenshots/02-home.png and b/docs/screenshots/02-home.png differ diff --git a/docs/screenshots/03-team.png b/docs/screenshots/03-team.png index 8d5f5f5..d5c4916 100644 Binary files a/docs/screenshots/03-team.png and b/docs/screenshots/03-team.png differ diff --git a/docs/screenshots/04-person.png b/docs/screenshots/04-person.png index 37cf5c5..f7d4a49 100644 Binary files a/docs/screenshots/04-person.png and b/docs/screenshots/04-person.png differ diff --git a/docs/screenshots/05-roles.png b/docs/screenshots/05-roles.png index daebe95..8c94921 100644 Binary files a/docs/screenshots/05-roles.png and b/docs/screenshots/05-roles.png differ diff --git a/docs/screenshots/06-cycles.png b/docs/screenshots/06-cycles.png index 3860f7e..a1d2335 100644 Binary files a/docs/screenshots/06-cycles.png and b/docs/screenshots/06-cycles.png differ diff --git a/docs/screenshots/07-writing-desk.png b/docs/screenshots/07-writing-desk.png index ca8436d..83d65df 100644 Binary files a/docs/screenshots/07-writing-desk.png and b/docs/screenshots/07-writing-desk.png differ diff --git a/docs/screenshots/08-promotions.png b/docs/screenshots/08-promotions.png index 4c82b33..36ec5a6 100644 Binary files a/docs/screenshots/08-promotions.png and b/docs/screenshots/08-promotions.png differ diff --git a/docs/screenshots/09-promotion-detail.png b/docs/screenshots/09-promotion-detail.png index 307c2aa..feb11d9 100644 Binary files a/docs/screenshots/09-promotion-detail.png and b/docs/screenshots/09-promotion-detail.png differ diff --git a/docs/screenshots/10-consistency.png b/docs/screenshots/10-consistency.png index 042110c..4860543 100644 Binary files a/docs/screenshots/10-consistency.png and b/docs/screenshots/10-consistency.png differ diff --git a/docs/screenshots/11-team-performance.png b/docs/screenshots/11-team-performance.png index 05d9284..c06dd7d 100644 Binary files a/docs/screenshots/11-team-performance.png and b/docs/screenshots/11-team-performance.png differ diff --git a/docs/screenshots/12-backfill.png b/docs/screenshots/12-backfill.png index abc6e7a..847c3f2 100644 Binary files a/docs/screenshots/12-backfill.png and b/docs/screenshots/12-backfill.png differ diff --git a/docs/screenshots/13-chat.png b/docs/screenshots/13-chat.png index fdeb254..f1810f7 100644 Binary files a/docs/screenshots/13-chat.png and b/docs/screenshots/13-chat.png differ diff --git a/docs/screenshots/14-settings.png b/docs/screenshots/14-settings.png index 109fa60..9ac06af 100644 Binary files a/docs/screenshots/14-settings.png and b/docs/screenshots/14-settings.png differ diff --git a/docs/screenshots/15-director-attention.png b/docs/screenshots/15-director-attention.png index cdabcdd..fea1810 100644 Binary files a/docs/screenshots/15-director-attention.png and b/docs/screenshots/15-director-attention.png differ diff --git a/docs/screenshots/16-writing-desk-director.png b/docs/screenshots/16-writing-desk-director.png index ca8436d..83d65df 100644 Binary files a/docs/screenshots/16-writing-desk-director.png and b/docs/screenshots/16-writing-desk-director.png differ diff --git a/docs/screenshots/17-consistency-director-packs.png b/docs/screenshots/17-consistency-director-packs.png index 042110c..4860543 100644 Binary files a/docs/screenshots/17-consistency-director-packs.png and b/docs/screenshots/17-consistency-director-packs.png differ diff --git a/docs/screenshots/18-team-director-briefing.png b/docs/screenshots/18-team-director-briefing.png index 05d9284..c06dd7d 100644 Binary files a/docs/screenshots/18-team-director-briefing.png and b/docs/screenshots/18-team-director-briefing.png differ diff --git a/docs/screenshots/19-promo-committee.png b/docs/screenshots/19-promo-committee.png index 307c2aa..feb11d9 100644 Binary files a/docs/screenshots/19-promo-committee.png and b/docs/screenshots/19-promo-committee.png differ diff --git a/docs/screenshots/20-sprint-pulse.png b/docs/screenshots/20-sprint-pulse.png new file mode 100644 index 0000000..a3604ce Binary files /dev/null and b/docs/screenshots/20-sprint-pulse.png differ diff --git a/docs/screenshots/README.md b/docs/screenshots/README.md index c87ffb4..0397d5a 100644 --- a/docs/screenshots/README.md +++ b/docs/screenshots/README.md @@ -9,18 +9,31 @@ pnpm screenshots # or: UI_URL=http://localhost:5173 PRM_PASSWORD=workbench pnpm screenshots ``` -Requires system Chrome (`google-chrome-stable`) and root `puppeteer-core`. +The script **erases and re-seeds** the local demo workspace so shots stay consistent. Requires system Chrome (`google-chrome-stable`) and root `puppeteer-core`. -Numbering matches the README Screens section (`01` unlock through `14` settings), including `11-team-performance`. - -Director / upward-pack close-ups (regenerated with the same script): +Numbering matches the README Screens section (`01` unlock through `14` settings), plus director close-ups and sprint pulse: | File | Surface | | --- | --- | +| `01-unlock.png` | Create workbench — empty vs demo | +| `02-home.png` | Command center | +| `03-team.png` | Team roster | +| `04-person.png` | Person dossier | +| `05-roles.png` | Roles | +| `06-cycles.png` | Cycles | +| `07-writing-desk.png` | Writing desk | +| `08-promotions.png` | Promotions list | +| `09-promotion-detail.png` | Promotion packet | +| `10-consistency.png` | Consistency | +| `11-team-performance.png` | Team performance | +| `12-backfill.png` | Backfill | +| `13-chat.png` | AI Chat | +| `14-settings.png` | Settings (section nav + appearance) | | `15-director-attention.png` | Home — Needs director attention | -| `16-writing-desk-director.png` | Writing desk — Skip-level talking points + Director card | -| `17-consistency-director-packs.png` | Consistency — calib room / briefing / encrypted bundle | +| `16-writing-desk-director.png` | Writing desk — Skip-level / Director card | +| `17-consistency-director-packs.png` | Consistency — calib / briefing / bundle | | `18-team-director-briefing.png` | Team performance — Director briefing | -| `19-promo-committee.png` | Promotion detail — Promo committee export | +| `19-promo-committee.png` | Promotion — Promo committee export | +| `20-sprint-pulse.png` | Sprint pulse | -`01-unlock` includes empty vs demo workspace choice; `14-settings` includes color mode + accent swatches. +`01-unlock` is the empty-vs-demo create screen. `14-settings` includes the sticky **On this page** nav (Appearance → Danger zone). diff --git a/scripts/capture-screenshots.mjs b/scripts/capture-screenshots.mjs index a05f889..fb99e3c 100644 --- a/scripts/capture-screenshots.mjs +++ b/scripts/capture-screenshots.mjs @@ -4,6 +4,10 @@ * pnpm screenshots * UI_URL=http://localhost:5173 PRM_PASSWORD=workbench pnpm screenshots * Expects UI + API running (defaults: UI :5173, API :8787). + * + * Recreates a demo workspace so shots stay consistent: + * 01-unlock = empty vs demo create choice + * remaining = seeded demo org (password workbench) */ import fs from "node:fs"; import path from "node:path"; @@ -16,54 +20,108 @@ const OUT = path.resolve("docs/screenshots"); fs.mkdirSync(OUT, { recursive: true }); -async function unlockToken() { - const res = await fetch(`${API}/api/workspace/unlock`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ password: PASSWORD }), - }); - if (!res.ok) throw new Error(`Unlock failed: ${res.status} ${await res.text()}`); - const json = await res.json(); - return json.token; -} - -async function apiJson(token, pathName, init) { +async function api(pathName, init = {}, token) { const res = await fetch(`${API}${pathName}`, { ...init, headers: { - "x-prm-token": token, + ...(token ? { "x-prm-token": token } : {}), ...(init?.body ? { "content-type": "application/json" } : {}), ...(init?.headers || {}), }, }); - if (!res.ok) throw new Error(`${pathName} → ${res.status} ${await res.text()}`); const text = await res.text(); - return text ? JSON.parse(text) : null; + const json = text ? JSON.parse(text) : null; + if (!res.ok) { + const err = new Error(`${pathName} → ${res.status} ${text}`); + err.status = res.status; + err.body = json; + throw err; + } + return json; +} + +async function unlockToken(password = PASSWORD) { + const json = await api("/api/workspace/unlock", { + method: "POST", + body: JSON.stringify({ password }), + }); + return json.token; } -async function ensureSubmittedPromo(token, promos) { - const draft = promos.find((p) => p.status === "draft") || promos[0]; - if (!draft?.id) return draft; +async function ensureFreshDemo() { + const status = await api("/api/workspace/status"); + if (status.initialized) { + // Prefer destroy when the vault password matches; fall back to unlock+destroy. + try { + await api("/api/workspace/destroy", { + method: "POST", + body: JSON.stringify({ password: PASSWORD, confirm: "DELETE" }), + }); + } catch (e) { + if (e.status === 401 || e.status === 403) { + throw new Error( + `Could not erase workspace for screenshots (password "${PASSWORD}" rejected or legal hold). ` + + `Stop the API and delete data/, or unlock with the correct password.`, + ); + } + throw e; + } + } +} + +async function ensureSubmittedPromo(token, _people, cycles) { + let promos = await api("/api/promotions", {}, token); + if (!Array.isArray(promos)) promos = []; + + let draft = promos.find((p) => p.status === "draft") || promos[0]; + if (!draft?.id) { + const people = await api("/api/people", {}, token); + const list = Array.isArray(people) ? people : []; + const eligible = + list.find((p) => p.managerId && p.targetNextRoleDefinitionId) || + list.find((p) => p.targetNextRoleDefinitionId); + if (!eligible?.id) { + console.warn( + "no person with target next role — skipping promo screenshots", + list.map((p) => ({ name: p.name, next: p.targetNextRoleDefinitionId })), + ); + return null; + } + const created = await api( + "/api/promotions", + { + method: "POST", + body: JSON.stringify({ personId: eligible.id, cycleId: cycles[0]?.id }), + }, + token, + ); + draft = { id: created.id, status: "draft" }; + } + if (draft.status === "submitted") return draft; - const detail = await apiJson(token, `/api/promotions/${draft.id}`); + const detail = await api(`/api/promotions/${draft.id}`, {}, token); const payload = detail?.review?.payload ?? {}; - await apiJson(token, `/api/promotions/${draft.id}`, { - method: "PUT", - body: JSON.stringify({ - status: "submitted", - decisionReason: "Screenshot gallery: show promo committee export", - payload: { - case_summary: payload.case_summary || "Ready for committee review with evidence-backed case.", - scope_examples: payload.scope_examples || "Led cross-team delivery on critical path work.", - remaining_gaps: payload.remaining_gaps || "Continue building skip-level visibility.", - recommendation: payload.recommendation || "ready", - }, - assessments: (detail.assessments || []).map((a) => ({ - roleExpectationId: a.roleExpectationId, - mark: a.mark, - })), - }), - }); + await api( + `/api/promotions/${draft.id}`, + { + method: "PUT", + body: JSON.stringify({ + status: "submitted", + decisionReason: "Screenshot gallery: show promo committee export", + payload: { + case_summary: payload.case_summary || "Ready for committee review with evidence-backed case.", + scope_examples: payload.scope_examples || "Led cross-team delivery on critical path work.", + remaining_gaps: payload.remaining_gaps || "Continue building skip-level visibility.", + recommendation: payload.recommendation || "ready", + }, + assessments: (detail.assessments || []).map((a) => ({ + roleExpectationId: a.roleExpectationId, + mark: a.mark === "emerging" ? "demonstrating" : a.mark, + })), + }), + }, + token, + ); return { ...draft, status: "submitted" }; } @@ -89,14 +147,7 @@ async function scrollHeadingIntoView(page, text) { } async function main() { - const token = await unlockToken(); - const people = await apiJson(token, "/api/people"); - const cycles = await apiJson(token, "/api/cycles"); - const promos = await apiJson(token, "/api/promotions"); - const promo = await ensureSubmittedPromo(token, promos); - const directs = people.filter((p) => p.managerId); - const person = directs[0] || people[0]; - const cycle = cycles[0]; + await ensureFreshDemo(); const browser = await puppeteer.launch({ executablePath: process.env.CHROME_PATH || "/usr/bin/google-chrome-stable", @@ -108,25 +159,44 @@ async function main() { const page = await browser.newPage(); await page.emulateMediaFeatures([{ name: "prefers-color-scheme", value: "dark" }]); - // Unlock screen + // 01 — create workbench (empty vs demo) await page.goto(UI, { waitUntil: "networkidle0" }); await page.evaluate(() => localStorage.removeItem("prm_token")); - await fetch(`${API}/api/workspace/lock`, { - method: "POST", - headers: { "content-type": "application/json", "x-prm-token": token }, - body: "{}", - }).catch(() => {}); await page.reload({ waitUntil: "networkidle0" }); - await page.waitForSelector(".unlock-card, .unlock-screen, input[type=password]", { timeout: 15000 }); - await new Promise((r) => setTimeout(r, 400)); + await page.waitForFunction( + () => + (document.body?.innerText || "").includes("Create workbench") || + (document.body?.innerText || "").includes("Empty workspace"), + { timeout: 15000 }, + ); + await new Promise((r) => setTimeout(r, 500)); await shot(page, "01-unlock"); - // Re-unlock for app pages - const token2 = await unlockToken(); - await page.evaluate((t) => localStorage.setItem("prm_token", t), token2); + // Seed demo org for the rest of the gallery + const init = await api("/api/workspace/init", { + method: "POST", + body: JSON.stringify({ name: "Demo Org", password: PASSWORD, seed: true }), + }); + const token = init.token; + await page.evaluate((t) => localStorage.setItem("prm_token", t), token); await page.goto(UI, { waitUntil: "networkidle0" }); - await page.waitForSelector(".app-shell", { timeout: 15000 }); - await new Promise((r) => setTimeout(r, 700)); + await page.waitForSelector(".app-shell", { timeout: 20000 }); + await new Promise((r) => setTimeout(r, 800)); + + const peopleRaw = await api("/api/people", {}, token); + const cyclesRaw = await api("/api/cycles", {}, token); + const people = Array.isArray(peopleRaw) ? peopleRaw : []; + const cycles = Array.isArray(cyclesRaw) ? cyclesRaw : []; + if (people.length === 0) { + throw new Error("Demo seed produced no people — cannot capture gallery"); + } + const promo = await ensureSubmittedPromo(token, people, cycles); + const directs = people.filter((p) => p.managerId); + const person = directs[0] || people[0]; + const cycle = cycles[0]; + if (!cycle?.id) throw new Error("Demo seed produced no cycle"); + if (!promo?.id) throw new Error("Could not create a promotion packet for gallery shots"); + await shot(page, "02-home"); await scrollHeadingIntoView(page, "Needs director attention"); await shot(page, "15-director-attention"); @@ -140,6 +210,7 @@ async function main() { ["08-promotions", "/promotions"], ["10-consistency", "/consistency"], ["11-team-performance", "/team-performance"], + ["20-sprint-pulse", "/sprint-pulse"], ["12-backfill", "/backfill"], ["13-chat", "/chat"], ["14-settings", "/settings"], @@ -157,6 +228,13 @@ async function main() { if (route === "/team-performance") { await page.waitForSelector(".tp-metrics, .tp-grid, h2", { timeout: 15000 }); } + if (route === "/sprint-pulse") { + await page.waitForFunction( + () => (document.body?.innerText || "").toLowerCase().includes("sprint") || + (document.body?.innerText || "").toLowerCase().includes("pulse"), + { timeout: 15000 }, + ); + } if (name === "07-writing-desk") { await page.waitForFunction( () => (document.body?.innerText || "").includes("Evidence digest"), @@ -177,6 +255,11 @@ async function main() { { timeout: 15000 }, ); } + if (name === "14-settings") { + await page.waitForSelector(".page-section-nav, #settings-appearance", { timeout: 15000 }); + await page.evaluate(() => window.scrollTo(0, 0)); + await new Promise((r) => setTimeout(r, 300)); + } await new Promise((r) => setTimeout(r, 450)); await shot(page, name);