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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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.
Expand All @@ -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)

Expand All @@ -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

Expand Down
Binary file modified docs/screenshots/02-home.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/03-team.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/04-person.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/05-roles.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/06-cycles.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/07-writing-desk.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/08-promotions.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/09-promotion-detail.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/10-consistency.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/11-team-performance.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/12-backfill.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/13-chat.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/14-settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/15-director-attention.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/16-writing-desk-director.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/17-consistency-director-packs.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/18-team-director-briefing.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/19-promo-committee.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/20-sprint-pulse.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
29 changes: 21 additions & 8 deletions docs/screenshots/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
197 changes: 140 additions & 57 deletions scripts/capture-screenshots.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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" };
}

Expand All @@ -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",
Expand All @@ -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");
Expand All @@ -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"],
Expand All @@ -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"),
Expand All @@ -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);

Expand Down
Loading