diff --git a/apps/ui/src/components/IntegrationsPanel.tsx b/apps/ui/src/components/IntegrationsPanel.tsx
index f7ab604..300a64c 100644
--- a/apps/ui/src/components/IntegrationsPanel.tsx
+++ b/apps/ui/src/components/IntegrationsPanel.tsx
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import type { IntegrationSettingsDTO, IntegrationSyncResult } from "@prm/shared";
import { api } from "../lib/api";
+import { Section } from "./PageChrome";
type IntegrationsState = {
jira: IntegrationSettingsDTO;
@@ -48,12 +49,48 @@ export function IntegrationsPanel() {
const jiraExtra = state.jira.extra ?? {};
return (
-
-
Integrations
-
- Pull cycle-scoped Jira / Linear issues and merged Bitbucket / GitHub PRs into each person’s dossier as
- achievements (citable on the writing desk). Tokens are encrypted with the workspace secret.
- Sync is explicit egress — no leaderboards, LOC scores, or Slack mining.
+ {
+ setBusy(true);
+ try {
+ const res = await api<{ count: number; results: IntegrationSyncResult[] }>(
+ "/api/integrations/sync-all",
+ { method: "POST", body: "{}" },
+ );
+ const imported = res.results.reduce(
+ (n, r) =>
+ n +
+ r.jira.imported +
+ r.bitbucket.imported +
+ r.github.imported +
+ (r.linear?.imported ?? 0),
+ 0,
+ );
+ setMsg(`Synced ${res.count} linked people (${imported} new achievements)`);
+ await load();
+ } catch (err) {
+ setMsg(err instanceof Error ? err.message : "Sync-all failed");
+ } finally {
+ setBusy(false);
+ }
+ }}
+ >
+ Sync all linked people
+
+ }
+ >
+
+ Sync is explicit egress — no leaderboards, LOC scores, or Slack mining. Link each direct’s accounts
+ on their person page, then sync there or use Sync all above (active review cycle window).
- Save Bitbucket
+ Save Bitbucket settings
- Test Bitbucket
+ Test Bitbucket connection
{state.bitbucket.lastSyncAt && (
@@ -357,7 +394,7 @@ export function IntegrationsPanel() {
- Save GitHub
+ Save GitHub settings
- Test GitHub
-
- {
- setBusy(true);
- try {
- const res = await api<{ count: number; results: IntegrationSyncResult[] }>(
- "/api/integrations/sync-all",
- { method: "POST", body: "{}" },
- );
- const imported = res.results.reduce(
- (n, r) =>
- n +
- r.jira.imported +
- r.bitbucket.imported +
- r.github.imported +
- (r.linear?.imported ?? 0),
- 0,
- );
- setMsg(`Synced ${res.count} linked people (${imported} new achievements)`);
- await load();
- } catch (err) {
- setMsg(err instanceof Error ? err.message : "Sync-all failed");
- } finally {
- setBusy(false);
- }
- }}
- >
- Sync all linked people
+ Test GitHub connection
{state.github.lastSyncAt && (
@@ -489,7 +495,7 @@ export function IntegrationsPanel() {
- Save Linear
+ Save Linear settings
- Test Linear
+ Test Linear connection
{state.linear.lastSyncAt && Last sync: {state.linear.lastSyncAt}
}
-
- Link each direct’s Jira / Bitbucket / GitHub / Linear account on their person page, then sync there or use Sync
- all above. Sync uses the active review cycle window.
-
{msg && {msg}
}
-
+
);
}
diff --git a/apps/ui/src/components/PageChrome.tsx b/apps/ui/src/components/PageChrome.tsx
index 80cd54c..e2dc54d 100644
--- a/apps/ui/src/components/PageChrome.tsx
+++ b/apps/ui/src/components/PageChrome.tsx
@@ -13,7 +13,7 @@ export function PageHeader({
secondary?: ReactNode;
}) {
return (
-
+
+ );
+}
+
+/**
+ * Scannable page section: clear heading, when/how copy, and an optional CTA.
+ * Keep `id` stable for sticky “On this page” anchors.
+ */
+export function Section({
+ id,
+ title,
+ when,
+ how,
+ action,
+ children,
+ className = "",
+}: {
+ id: string;
+ title: string;
+ /** When to open this section (one short sentence). */
+ when: string;
+ /** How to use it (one short sentence). */
+ how: string;
+ action?: ReactNode;
+ children?: ReactNode;
+ className?: string;
+}) {
+ const titleId = `${id}-heading`;
+ return (
+
+
+
+
{title}
+
+
+
When
+ {when}
+
+
+
How
+ {how}
+
+
+
+ {action != null ? {action}
: null}
+
+ {children ? {children}
: null}
+
);
}
@@ -41,7 +92,7 @@ export function NextStep({
tone?: "brand" | "warn" | "ok";
}) {
return (
-
+
{label}
{detail}
diff --git a/apps/ui/src/components/RetentionPanel.tsx b/apps/ui/src/components/RetentionPanel.tsx
index 64cf3ad..360ada5 100644
--- a/apps/ui/src/components/RetentionPanel.tsx
+++ b/apps/ui/src/components/RetentionPanel.tsx
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import type { WorkspaceSettingsDTO } from "@prm/shared";
import { api } from "../lib/api";
+import { Section } from "./PageChrome";
export function RetentionPanel() {
const [settings, setSettings] = useState
(null);
@@ -18,14 +19,43 @@ export function RetentionPanel() {
if (!settings) return Loading retention…
;
return (
-
-
-
Retention & legal hold
-
+
{
+ setBusy(true);
+ try {
+ const next = await api("/api/workspace/settings", {
+ method: "PUT",
+ body: JSON.stringify({
+ retention: settings.retention,
+ legalHold: settings.legalHold,
+ }),
+ });
+ setSettings({ ...settings, ...next });
+ setMsg("Retention settings saved");
+ await load();
+ } catch (e) {
+ setMsg(e instanceof Error ? e.message : "Save failed");
+ } finally {
+ setBusy(false);
+ }
+ }}
+ >
+ Save retention settings
+
+ }
+ >
- Default policy keeps the current + previous cycle. Older closed cycles can be
- purged when legal hold is off. Purge marks cycles as purged (audit trail kept) — it does not
- shred the vault.
+ Default policy keeps the current + previous cycle. Purge marks cycles as purged
+ (audit trail kept) — it does not shred the vault.
Retention policy
@@ -55,32 +85,6 @@ export function RetentionPanel() {
-
{
- setBusy(true);
- try {
- const next = await api("/api/workspace/settings", {
- method: "PUT",
- body: JSON.stringify({
- retention: settings.retention,
- legalHold: settings.legalHold,
- }),
- });
- setSettings({ ...settings, ...next });
- setMsg("Retention settings saved");
- await load();
- } catch (e) {
- setMsg(e instanceof Error ? e.message : "Save failed");
- } finally {
- setBusy(false);
- }
- }}
- >
- Save retention
-
No cycles eligible for purge under the current policy.
)}
{msg && {msg}
}
-
+
);
}
diff --git a/apps/ui/src/pages/ConsistencyPage.tsx b/apps/ui/src/pages/ConsistencyPage.tsx
index 3fb640b..f64e1e5 100644
--- a/apps/ui/src/pages/ConsistencyPage.tsx
+++ b/apps/ui/src/pages/ConsistencyPage.tsx
@@ -4,7 +4,7 @@ import type { CalibBundleDTO, ConsistencyDTO, CycleDTO } from "@prm/shared";
import { competencyLabel } from "@prm/shared";
import { api } from "../lib/api";
import { downloadBinaryExport, openHtmlExport } from "../lib/exports";
-import { NextStep, PageHeader } from "../components/PageChrome";
+import { NextStep, PageHeader, Section } from "../components/PageChrome";
import { PageSectionsLayout } from "../components/PageSectionsLayout";
export function ConsistencyPage() {
@@ -106,12 +106,28 @@ export function ConsistencyPage() {
{ id: "cons-cards", label: "People" },
]}
>
-
-
Calibration bundles
-
- Director round-trip: export cards, capture decisions in the form (or paste JSON), then import
- back into draft manager reviews for the same cycle.
-
+
{
+ try {
+ await openHtmlExport(`/api/exports/calib-room/${data.cycleId}?format=html`);
+ setCalibMsg("Opened calibration room pack — Print → Save as PDF");
+ } catch (e) {
+ setCalibMsg(e instanceof Error ? e.message : "Calib pack failed");
+ }
+ }}
+ >
+ Print calib room pack
+
+ }
+ >
- Export calib JSON
-
- {
- try {
- await openHtmlExport(`/api/exports/calib-room/${data.cycleId}?format=html`);
- setCalibMsg("Opened calibration room pack — Print → Save as PDF");
- } catch (e) {
- setCalibMsg(e instanceof Error ? e.message : "Calib pack failed");
- }
- }}
- >
- Print calib room pack
+ Export calibration JSON
- Director briefing
+ Open director briefing
@@ -205,7 +207,7 @@ export function ConsistencyPage() {
}
}}
>
- Encrypted read-only bundle
+ Download encrypted bundle
@@ -316,10 +318,10 @@ export function ConsistencyPage() {
await load(data.cycleId);
}}
>
- Import decisions
+ Import calibration decisions
{calibMsg && {calibMsg}
}
-
+
{thin.length > 0 ? (
)}
-
-
Overall distribution
+
{["1", "2", "3", "4", "5", "unset"].map((bucket) => {
const n = data.distribution[bucket] ?? 0;
@@ -360,9 +366,22 @@ export function ConsistencyPage() {
);
})}
-
+
-
+
+ Open first writing desk
+
+ ) : undefined
+ }
+ >
+
{data.cards.map((card) => (
@@ -396,11 +415,12 @@ export function ConsistencyPage() {
))}
- Writing desk
+ Open writing desk
))}
-
+
+
)}
diff --git a/apps/ui/src/pages/CyclesPage.tsx b/apps/ui/src/pages/CyclesPage.tsx
index 36a7f47..7f683d9 100644
--- a/apps/ui/src/pages/CyclesPage.tsx
+++ b/apps/ui/src/pages/CyclesPage.tsx
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import type { CycleDTO, PersonDTO } from "@prm/shared";
import { api } from "../lib/api";
-import { NextStep, PageHeader } from "../components/PageChrome";
+import { NextStep, PageHeader, Section } from "../components/PageChrome";
import { PageSectionsLayout } from "../components/PageSectionsLayout";
const PHASE_STATUSES = [
@@ -51,7 +51,7 @@ export function CyclesPage() {
job="Export fillable JSON for self/peer reviews, send outside the app, then import completed files. ICs do not log in."
primary={
cycles.length === 0 ? (
-
Create cycle
+
Create cycle with directs
) : (
Download JSON bundle
)
@@ -66,7 +66,7 @@ export function CyclesPage() {
tone="warn"
label="Create your first cycle"
detail="A cycle snapshots your directs as participants and unlocks self/peer bundle export."
- action={
Create cycle (directs) }
+ action={
Create cycle with directs }
/>
) : (
-
- Create cycle (directs)
+ Create cycle with directs
-
+
{selected && (
-
-
Phase wizard — {selected.name}
-
- Soft gates only: advance phases when ready. Waive missing self/peer from the writing desk with a reason.
-
+
+ Update cycle window
+
+ }
+ >
{PHASE_STATUSES.map((phase) => (
- {phase.label}
+ Set phase: {phase.label}
))}
-
Update window
+
Update cycle window
-
+
)}
-
+
-
-
2. Import completed bundle
+
{
+ const parsed = JSON.parse(importText) as Record;
+ const fields = (parsed.fields ?? {}) as Record;
+ if (parsed.kind === "self" && !fields.top_outcomes) {
+ fields.top_outcomes = "Shipped reliability work; mentored a new joiner; unblocked checkout migration.";
+ fields.growth_areas = "Take more cross-team design leadership.";
+ fields.proposed_goals = "Own Q3 platform migration milestones.";
+ parsed.fields = fields;
+ }
+ if (parsed.kind === "peer" && !fields.strengths) {
+ fields.context = "Paired on API design for 6 weeks.";
+ fields.strengths = "Clear written RFCs; calm under incident pressure.";
+ fields.growth = "Could socialize designs earlier with partner teams.";
+ parsed.fields = fields;
+ parsed.peerAuthorName = parsed.peerAuthorName || "Peer Reviewer";
+ }
+ await api("/api/bundles/import", {
+ method: "POST",
+ body: JSON.stringify({ bundle: parsed, sourceFilename: "paste.json" }),
+ });
+ const sid = String(parsed.subjectPersonId ?? subjectId);
+ const cid = String(parsed.cycleId ?? selectedCycle);
+ setMessage("Imported. Next: open the writing desk and draft the manager review.");
+ setImportText("");
+ setSubjectId(sid);
+ setSelectedCycle(cid);
+ }}
+ >
+ Import completed JSON
+
+ }
+ >
Paste completed bundle JSON
setImportText(e.target.value)} placeholder='{ "kind": "self", ... }' />
- {
- const parsed = JSON.parse(importText) as Record;
- const fields = (parsed.fields ?? {}) as Record;
- if (parsed.kind === "self" && !fields.top_outcomes) {
- fields.top_outcomes = "Shipped reliability work; mentored a new joiner; unblocked checkout migration.";
- fields.growth_areas = "Take more cross-team design leadership.";
- fields.proposed_goals = "Own Q3 platform migration milestones.";
- parsed.fields = fields;
- }
- if (parsed.kind === "peer" && !fields.strengths) {
- fields.context = "Paired on API design for 6 weeks.";
- fields.strengths = "Clear written RFCs; calm under incident pressure.";
- fields.growth = "Could socialize designs earlier with partner teams.";
- parsed.fields = fields;
- parsed.peerAuthorName = parsed.peerAuthorName || "Peer Reviewer";
- }
- await api("/api/bundles/import", {
- method: "POST",
- body: JSON.stringify({ bundle: parsed, sourceFilename: "paste.json" }),
- });
- const sid = String(parsed.subjectPersonId ?? subjectId);
- const cid = String(parsed.cycleId ?? selectedCycle);
- setMessage("Imported. Next: open the writing desk and draft the manager review.");
- setImportText("");
- setSubjectId(sid);
- setSelectedCycle(cid);
- }}
- >
- Import completed JSON
-
{selectedCycle && subjectId && (
- 3. Open writing desk for {subject?.name ?? "subject"}
+ Open writing desk for {subject?.name ?? "subject"}
)}
-
+
{message && (
-
- Cycle
- setCycleId(e.target.value || null)}>
- {cycles.length === 0 && No cycles yet }
- {cycles.map((c) => (
- {c.name}
- ))}
-
- {active && {active.windowStart} → {active.windowEnd} · {active.status} }
-
+
+
+
+ Cycle
+
+ setCycleId(e.target.value || null)}
+ >
+ {cycles.length === 0 && No cycles yet }
+ {cycles.map((c) => (
+ {c.name}
+ ))}
+
+ {active && (
+
+ {active.windowStart} → {active.windowEnd} · {active.status}
+
+ )}
+
+
{attention && attention.items.length > 0 && (
-
-
Needs director attention
-
- Flags only — stagnant ratings, thin evidence, promo-ready, open concern counts. No private note bodies.
-
+
+ Open consistency packs
+
+ }
+ >
{attention.items.slice(0, 10).map((item) => (
@@ -286,25 +309,26 @@ export function HomePage() {
{item.reasons.join(" · ")}
{item.overall != null ? ` · overall ${item.overall}` : ""}
{" "}
-
- Desk
+
+ Open writing desk
))}
-
- Open consistency / director packs
-
-
+
)}
{dueWeek.length > 0 && (
-
-
Due this week
-
- Sticky tray for cycle work ending within 7 days. OS notification fires once per session when permission is
- granted (app must be open).
-
+
{dueWeek.slice(0, 12).map((d) => (
@@ -318,15 +342,29 @@ export function HomePage() {
))}
-
+
)}
{cycleStats && (
-
-
Cycle dashboard
-
- Writing time, evidence coverage, and thin dossiers for {cycleStats.cycleName}.
-
+
+
+ Open team performance
+
+
+ Open sprint pulse
+
+ >
+ }
+ >
Avg writing time
@@ -358,29 +396,26 @@ export function HomePage() {
{cycleStats.thinDossierCount > 0 ? (
<>
{" "}
-
Backfill
+
+ Open backfill
+
>
) : null}
-
-
Charts
-
- Team performance
-
- {" · "}
-
Sprint pulse
-
-
+
)}
{cycleId && nudges.length > 0 && (
-
-
-
Deadline nudges
+ {
const text = nudges
.map((n) => `${n.personName}${n.email ? ` <${n.email}>` : ""} — ${n.mailtoHref}`)
@@ -393,9 +428,10 @@ export function HomePage() {
}
}}
>
- Copy all
+ Copy all nudge links
-
+ }
+ >
{nudges.length} missing self-review
{nudgeDays != null ? ` · ${nudgeDays} day(s) until window end` : ""}
@@ -405,15 +441,31 @@ export function HomePage() {
{n.personName} {" "}
({n.selfStatus}) {" "}
- mailto
+ Email nudge
))}
{nudgeMsg &&
{nudgeMsg}
}
-
+
)}
-
+
+ Manage cycle roster
+
+ ) : (
+
+ Create cycle
+
+ )
+ }
+ >
{items.length === 0 ? (
{cycleId ? "No participants in this cycle." : "Create a cycle to see your writing queue."}
@@ -422,14 +474,14 @@ export function HomePage() {
- Person
- Role
- Self
- Self pipeline
- Peers
- Peer pipeline
- Manager
- Your move
+ Person
+ Role
+ Self
+ Self pipeline
+ Peers
+ Peer pipeline
+ Manager
+ Your move
@@ -446,16 +498,30 @@ export function HomePage() {
} else if (needsSelf) {
cta = (
- Get self-review
- Write
+ Export self-review pack
+
+ Open writing desk
+
);
} else if (finalized) {
- cta = Open / export;
+ cta = (
+
+ Open or export draft
+
+ );
} else if (item.managerStatus === "draft") {
- cta = Continue writing;
+ cta = (
+
+ Continue writing
+
+ );
} else {
- cta = Write review;
+ cta = (
+
+ Start writing review
+
+ );
}
return (
@@ -481,7 +547,7 @@ export function HomePage() {
)}
-
+
);
diff --git a/apps/ui/src/pages/PersonPage.tsx b/apps/ui/src/pages/PersonPage.tsx
index ef8c65e..bba036a 100644
--- a/apps/ui/src/pages/PersonPage.tsx
+++ b/apps/ui/src/pages/PersonPage.tsx
@@ -12,7 +12,7 @@ import type {
PersonTimelineDTO,
} from "@prm/shared";
import { api, getToken, apiUrl } from "../lib/api";
-import { NextStep, PageHeader } from "../components/PageChrome";
+import { NextStep, PageHeader, Section } from "../components/PageChrome";
import { PageSectionsLayout, type SectionNavItem } from "../components/PageSectionsLayout";
import { DeliveryRollupPanel } from "../components/DeliveryRollupPanel";
@@ -170,34 +170,12 @@ export function PersonPage() {
{activeCycle && (
-
-
Mid-cycle manager change
-
- Reassign for {activeCycle.name} : supersedes prior manager drafts/finals as contribution
- history and opens a fresh draft for the new manager.
-
-
-
- New manager
- setReassignManagerId(e.target.value)}>
- Select…
- {roster
- .filter((p) => p.id !== person.id)
- .map((p) => (
-
- {p.name}
-
- ))}
-
-
-
- Reason
- setReassignReason(e.target.value)}
- placeholder="Org change / coverage"
- />
-
+
Reassign in cycle
+ }
+ >
+
+
+ New manager
+ setReassignManagerId(e.target.value)}>
+ Select…
+ {roster
+ .filter((p) => p.id !== person.id)
+ .map((p) => (
+
+ {p.name}
+
+ ))}
+
+
+
+ Reason
+ setReassignReason(e.target.value)}
+ placeholder="Org change / coverage"
+ />
+
{reassignMsg && {reassignMsg}
}
-
+
)}
-
-
Timeline
-
- Chronological dossier, reviews, concerns, and rating decisions. Use this to prep a 1:1 or reconstruct
- why the story changed.
-
+
{!timeline || timeline.entries.length === 0 ? (
No timeline events yet.
) : (
@@ -249,14 +251,19 @@ export function PersonPage() {
))}
)}
-
+
-
-
Integrations
-
- Link remote accounts, then pull tickets / merged PRs for the active review window into achievements.
- Configure credentials in Settings → Integrations.
-
+
+ Open integration settings
+
+ }
+ >
{extLinks.length === 0 ? (
No external accounts linked yet.
) : (
@@ -318,7 +325,7 @@ export function PersonPage() {
}
}}
>
- Search
+ Search remote users
- Link
+ Link account
))}
)}
{linkMsg && {linkMsg}
}
-
+
{id ?
: null}
-
-
Feedback
-
EM-entered notes from 1:1s, partners, or incidents. Author names stay on your workspace.
+
{dossier.feedback.length === 0 && No feedback yet — capture a coaching or praise note.
}
{dossier.feedback.map((f) => (
@@ -532,11 +550,14 @@ export function PersonPage() {
Add feedback
-
+
-
-
Documents
-
PDF/DOCX/PPTX/CSV/images — allowlisted. Sealed into the encrypted vault when you lock the workspace.
+
{(dossier.documents ?? []).map((d) => (
@@ -592,16 +613,16 @@ export function PersonPage() {
Upload document
{docMsg && {docMsg}
}
-
+
{captureMsg && {captureMsg}
}
-
-
Meeting summaries
-
- Structured notes from 1:1s / meetings. Default visibility is EM only . Raw transcripts stay
- private on the dossier. Confirm consent before storing — local vault notes are not a legal privilege.
-
+
{meetings.length === 0 ? (
No meeting summaries yet.
) : (
@@ -721,13 +742,14 @@ export function PersonPage() {
Save meeting summary
-
+
-
-
Concern log
-
- Private performance concerns and follow-up plans. AI is off by default — these stay out of digests and drafts unless you later opt a concern in.
-
+
{concerns.length === 0 ? (
No concerns logged.
) : (
@@ -828,7 +850,7 @@ export function PersonPage() {
Add concern
-
+
{person.targetNextRoleTitle && (
diff --git a/apps/ui/src/pages/RolesPage.tsx b/apps/ui/src/pages/RolesPage.tsx
index 6e1befe..ded56c9 100644
--- a/apps/ui/src/pages/RolesPage.tsx
+++ b/apps/ui/src/pages/RolesPage.tsx
@@ -7,7 +7,7 @@ import type {
RoleFrameworkInfo,
} from "@prm/shared";
import { api, getToken, apiUrl } from "../lib/api";
-import { NextStep, PageHeader } from "../components/PageChrome";
+import { NextStep, PageHeader, Section } from "../components/PageChrome";
import { PageSectionsLayout } from "../components/PageSectionsLayout";
type ExpectationEdit = { id?: string; kind: "responsibility" | "expectation"; text: string };
@@ -386,9 +386,18 @@ export function RolesPage() {
{ id: "roles-assign", label: "Assign" },
]}
>
-
-
-
Catalog
+
+
+ Add role
+
+ }
+ >
{roles.length === 0 && No roles yet — add one or extract from a document.
}
{roles.map((r) => (
))}
-
-
-
{selected?.title ?? "Select a role"}
+
+
startEdit(selected)}>
+ Edit role
+
+ ) : undefined
+ }
+ >
{selected ? (
<>
+ {selected.title}
{selected.summary}
{selected.expectations.map((e) => (
@@ -431,18 +452,15 @@ export function RolesPage() {
Next keys: {selected.nextRoleKeys.join(", ")}
)}
- startEdit(selected)}>
- Edit role
-
void deleteRole(selected)}>
- Delete
+ Delete role
>
) : (
Pick a role from the catalog or create one.
)}
-
+
{editing && (
@@ -558,7 +576,7 @@ export function RolesPage() {
Add expectation
- {editor.id ? "Save changes" : "Create role"}
+ {editor.id ? "Save role changes" : "Create role"}
setEditing(false)}>
Cancel
@@ -567,12 +585,17 @@ export function RolesPage() {
)}
-
+
+
+ }
+ >
{
e.preventDefault();
await api("/api/roles/assign", {
@@ -650,7 +678,6 @@ export function RolesPage() {
await load();
}}
>
- Assign role
{selectedPerson
? `Editing ${selectedPerson.name}${selectedPerson.roleTitle ? ` (currently ${selectedPerson.roleTitle})` : " (unassigned)"}.`
@@ -690,13 +717,9 @@ export function RolesPage() {
-
-
- Save assignment
-
- {saved && {saved} }
-
+ {saved && {saved}
}
+
);
diff --git a/apps/ui/src/pages/SettingsPage.tsx b/apps/ui/src/pages/SettingsPage.tsx
index 5ffc3ab..00a2ffc 100644
--- a/apps/ui/src/pages/SettingsPage.tsx
+++ b/apps/ui/src/pages/SettingsPage.tsx
@@ -3,7 +3,7 @@ import type { AuditVerifyResult } from "@prm/shared";
import { api } from "../lib/api";
import { useTheme } from "../hooks/useTheme";
import { ACCENTS, type Accent, type ColorMode } from "../lib/theme";
-import { NextStep, PageHeader } from "../components/PageChrome";
+import { NextStep, PageHeader, Section } from "../components/PageChrome";
import { PageSectionsLayout, type SectionNavItem } from "../components/PageSectionsLayout";
import { IntegrationsPanel } from "../components/IntegrationsPanel";
import { RetentionPanel } from "../components/RetentionPanel";
@@ -122,19 +122,18 @@ export function SettingsPage({
setMsg(res.ok ? `Connection OK (${res.model})` : `Failed: ${res.error}`);
}}
>
- Test connection
+ Test AI connection
}
/>
-
-
-
Appearance
-
-
- Color mode and accent are stored in this browser. System follows your OS preference ({resolved} right now).
-
+
+
-
-
-
+
-
-
-
+
-
-
Activity audit
-
- Hash-chained local activity log with append-only SQLite triggers. Verify recomputes the chain;
- export signs a pack with the unlocked workspace key. Tamper-evident for this machine — not
- external legal custody (anyone with the vault password can re-sign).
+
+ {
+ setAuditBusy(true);
+ try {
+ const result = await api("/api/activity/verify");
+ setAudit(result);
+ setMsg(result.ok ? `Chain OK (${result.count} events)` : `Chain broken: ${result.error}`);
+ } catch (e) {
+ setMsg(e instanceof Error ? e.message : "Verify failed");
+ } finally {
+ setAuditBusy(false);
+ }
+ }}
+ >
+ Verify audit chain
+
+ {
+ setAuditBusy(true);
+ try {
+ const pack = await api>("/api/activity/export", {
+ method: "POST",
+ body: "{}",
+ });
+ const blob = new Blob([JSON.stringify(pack, null, 2)], { type: "application/json" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `prm-audit-${new Date().toISOString().slice(0, 10)}.json`;
+ a.click();
+ URL.revokeObjectURL(url);
+ const v = pack.verification as AuditVerifyResult | undefined;
+ if (v) setAudit(v);
+ setMsg("Audit pack downloaded");
+ } catch (e) {
+ setMsg(e instanceof Error ? e.message : "Export failed");
+ } finally {
+ setAuditBusy(false);
+ }
+ }}
+ >
+ Export signed audit pack
+
+ >
+ }
+ >
+
+ Append-only SQLite triggers keep a local chain. Tamper-evident for this machine — not external legal
+ custody (anyone with the vault password can re-sign).
-
-
{
- setAuditBusy(true);
- try {
- const result = await api("/api/activity/verify");
- setAudit(result);
- setMsg(result.ok ? `Chain OK (${result.count} events)` : `Chain broken: ${result.error}`);
- } catch (e) {
- setMsg(e instanceof Error ? e.message : "Verify failed");
- } finally {
- setAuditBusy(false);
- }
- }}
- >
- Verify chain
-
-
{
- setAuditBusy(true);
- try {
- const pack = await api>("/api/activity/export", {
- method: "POST",
- body: "{}",
- });
- const blob = new Blob([JSON.stringify(pack, null, 2)], { type: "application/json" });
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = `prm-audit-${new Date().toISOString().slice(0, 10)}.json`;
- a.click();
- URL.revokeObjectURL(url);
- const v = pack.verification as AuditVerifyResult | undefined;
- if (v) setAudit(v);
- setMsg("Audit pack downloaded");
- } catch (e) {
- setMsg(e instanceof Error ? e.message : "Export failed");
- } finally {
- setAuditBusy(false);
- }
- }}
- >
- Export signed pack
-
-
{audit && (
{audit.ok ? "Intact" : "Broken"} · {audit.count} events
@@ -252,15 +252,14 @@ export function SettingsPage({
{audit.error ? ` · ${audit.error}` : ""}
)}
-
+
-
-
Backup & restore
-
- Encrypted snapshots of the unlocked SQLite DB under data/backups. Restore replaces the live
- database — confirm carefully.
-
-
+
- Create backup
+ Create encrypted backup
-
+ }
+ >
{backups.length === 0 ? (
No backups yet.
) : (
@@ -320,13 +320,13 @@ export function SettingsPage({
}
}}
>
- Restore
+ Restore this backup
))}
)}
-
+
{needsKey ? (
- Test connection
+ Test AI connection
}
/>
@@ -365,10 +365,16 @@ export function SettingsPage({
/>
)}
-
+
-
-
Danger zone
-
- Permanently erase this local workspace (database, vault, uploads, and backups), then create a
- new empty or demo workspace. Legal hold must be off. This cannot be undone.
-
- {!destroyOpen ? (
-
+
- ) : (
+ ) : undefined
+ }
+ >
+
+ Permanently erase this local workspace (database, vault, uploads, and backups), then create a
+ new empty or demo workspace. This cannot be undone.
+
+ {destroyOpen ? (
{
@@ -613,60 +623,65 @@ export function SettingsPage({
setDestroyError(null);
}}
>
- Cancel
+ Cancel delete
- )}
-
+ ) : null}
+
-
-
Desktop updates
-
- Packaged builds serve the UI from the local API (same-origin) to avoid WebView “Failed to fetch”. Check GitHub
- Releases for a newer installer; signed auto-update needs Tauri updater keys in CI.
-
-
{
- try {
- let localVersion = "0.1.0";
- const invoke = (window as unknown as {
- __TAURI__?: { core?: { invoke?: (cmd: string) => Promise } };
- }).__TAURI__?.core?.invoke;
- if (invoke) {
- try {
- localVersion = await invoke("get_app_version");
- } catch {
- /* browser / unsigned */
+ try {
+ let localVersion = "0.1.0";
+ const invoke = (window as unknown as {
+ __TAURI__?: { core?: { invoke?: (cmd: string) => Promise } };
+ }).__TAURI__?.core?.invoke;
+ if (invoke) {
+ try {
+ localVersion = await invoke("get_app_version");
+ } catch {
+ /* browser / unsigned */
+ }
}
+ const res = await fetch(
+ "https://api.github.com/repos/Modsofthenation/PerformanceReviewManager/releases/latest",
+ { headers: { Accept: "application/vnd.github+json" } },
+ );
+ if (!res.ok) {
+ setMsg(`Could not reach GitHub releases (${res.status}). Download artifacts from Actions on main.`);
+ return;
+ }
+ const json = (await res.json()) as { tag_name?: string; html_url?: string; name?: string };
+ const tag = json.tag_name ?? json.name ?? "unknown";
+ setMsg(
+ `Installed app version ${localVersion}. Latest GitHub release: ${tag}. ${json.html_url ?? ""}`,
+ );
+ } catch (e) {
+ setMsg(
+ e instanceof Error
+ ? `${e.message} — use CI artifact desktop-windows-x86_64 / desktop-linux / desktop-macos`
+ : "Update check failed",
+ );
}
- const res = await fetch(
- "https://api.github.com/repos/Modsofthenation/PerformanceReviewManager/releases/latest",
- { headers: { Accept: "application/vnd.github+json" } },
- );
- if (!res.ok) {
- setMsg(`Could not reach GitHub releases (${res.status}). Download artifacts from Actions on main.`);
- return;
- }
- const json = (await res.json()) as { tag_name?: string; html_url?: string; name?: string };
- const tag = json.tag_name ?? json.name ?? "unknown";
- setMsg(
- `Installed app version ${localVersion}. Latest GitHub release: ${tag}. ${json.html_url ?? ""}`,
- );
- } catch (e) {
- setMsg(
- e instanceof Error
- ? `${e.message} — use CI artifact desktop-windows-x86_64 / desktop-linux / desktop-macos`
- : "Update check failed",
- );
- }
- }}
- >
- Check for updates
-
-
+ }}
+ >
+ Check for desktop updates
+
+ }
+ >
+
+ Packaged builds serve the UI from the local API (same-origin) to avoid WebView “Failed to fetch”.
+
+
);
diff --git a/apps/ui/src/pages/SprintPulsePage.tsx b/apps/ui/src/pages/SprintPulsePage.tsx
index 2c98617..9c1e797 100644
--- a/apps/ui/src/pages/SprintPulsePage.tsx
+++ b/apps/ui/src/pages/SprintPulsePage.tsx
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import type { SprintPulseDTO } from "@prm/shared";
import { api } from "../lib/api";
-import { NextStep, PageHeader } from "../components/PageChrome";
+import { NextStep, PageHeader, Section } from "../components/PageChrome";
import { PageSectionsLayout } from "../components/PageSectionsLayout";
const PRESETS = [
@@ -48,7 +48,7 @@ export function SprintPulsePage() {
Team performance}
+ primary={ Open team performance}
/>
{error && (
@@ -72,7 +72,12 @@ export function SprintPulsePage() {
{ id: "pulse-people", label: "People" },
]}
>
-
+
Preset
@@ -119,11 +124,7 @@ export function SprintPulsePage() {
/>
-
- Counts achievements (including Jira / Bitbucket sync), feedback themes, documents, and open
- concerns dated in the window. Flags people whose manager-draft cites are all older than the window.
-
-
+
{!data ? (
Loading…
@@ -137,7 +138,7 @@ export function SprintPulsePage() {
? `${data.summary.staleCitePeople} draft(s) only cite older evidence. Prefer fresher cites before finalize.`
: "Evidence freshness looks healthy for drafts that have cites."
}
- action={ Open team}
+ action={ Open team roster}
/>
@@ -165,8 +166,12 @@ export function SprintPulsePage() {
-
-
1:1 themes in window
+
{data.themes.length === 0 ? (
No themes tagged on feedback in this range.
) : (
@@ -187,12 +192,14 @@ export function SprintPulsePage() {
))}
)}
-
-
-
Evidence freshness
-
- In-window dossier items vs older items, plus whether current draft cites are stale.
-
+
+
Open team roster}
+ >
{data.summary.staleCitePeople} people with stale-only cites
@@ -205,11 +212,16 @@ export function SprintPulsePage() {
synced tickets / PRs in window
-
+
-
-
People
+
Open team roster}
+ >
@@ -250,7 +262,7 @@ export function SprintPulsePage() {
- Dossier
+ Open dossier
@@ -258,7 +270,7 @@ export function SprintPulsePage() {
-
+
>
)}
diff --git a/apps/ui/src/pages/TeamPerformancePage.tsx b/apps/ui/src/pages/TeamPerformancePage.tsx
index 52c1408..7ad673b 100644
--- a/apps/ui/src/pages/TeamPerformancePage.tsx
+++ b/apps/ui/src/pages/TeamPerformancePage.tsx
@@ -4,7 +4,7 @@ import type { CycleCompareDTO, CycleDTO, RatingTrendsDTO, TeamPerformanceDTO } f
import { competencyLabel } from "@prm/shared";
import { api } from "../lib/api";
import { openHtmlExport } from "../lib/exports";
-import { NextStep, PageHeader } from "../components/PageChrome";
+import { NextStep, PageHeader, Section } from "../components/PageChrome";
import { PageSectionsLayout, type SectionNavItem } from "../components/PageSectionsLayout";
const PROMO_LABELS: Record = {
@@ -344,30 +344,43 @@ export function TeamPerformancePage() {
{ id: "tp-compare", label: "Compare" },
] satisfies SectionNavItem[]}
>
-
-
-
Avg overall
-
{data.avgOverall ?? "—"}
-
-
-
Evidence coverage
-
{data.evidence.coveragePct}%
-
· avg {data.evidence.avgLinkedSources} sources
-
-
-
Thin dossiers
-
{data.evidence.thinDossierCount}
-
/ {data.participantCount}
-
-
-
Open concerns
-
{data.openConcerns}
+
+
+
+
Avg overall
+
{data.avgOverall ?? "—"}
+
+
+
Evidence coverage
+
{data.evidence.coveragePct}%
+
· avg {data.evidence.avgLinkedSources} sources
+
+
+
Thin dossiers
+
{data.evidence.thinDossierCount}
+
/ {data.participantCount}
+
+
+
Open concerns
+
{data.openConcerns}
+
-
+
-
+
+
-
Overall distribution
+
Overall distribution
Watch for clustering (everyone a 3 or 4) before you finalize.
@@ -375,7 +388,7 @@ export function TeamPerformancePage() {
-
Evidence vs overall
+
Evidence vs overall
Thin dossiers sit lower; extreme scores should sit higher.
@@ -383,7 +396,7 @@ export function TeamPerformancePage() {
-
Competency averages
+
Competency averages
Team mean on the 1–5 scale (people with that competency scored).
@@ -410,7 +423,7 @@ export function TeamPerformancePage() {
-
Promo readiness
+ Promo readiness
({
key,
@@ -422,7 +435,7 @@ export function TeamPerformancePage() {
-
Writing time
+ Writing time
({
key: b.id,
@@ -434,7 +447,7 @@ export function TeamPerformancePage() {
-
Levels
+
Levels
{data.levels.length === 0 ? (
No people in this cycle.
) : (
@@ -450,7 +463,7 @@ export function TeamPerformancePage() {
-
1:1 themes
+
1:1 themes
Tags from feedback notes across the team (year-round coaching signal).
@@ -467,10 +480,15 @@ export function TeamPerformancePage() {
/>
)}
-
+
+
-
-
People
+
@@ -546,7 +564,7 @@ export function TeamPerformancePage() {
- Desk
+ Open writing desk
@@ -555,15 +573,15 @@ export function TeamPerformancePage() {
-
+
{trends && trends.cycles.length > 0 && (
-
-
-
Rating trends across cycles
-
- Team averages (manager ratings). Spot stagnation or sudden jumps before the next packet.
-
+
@@ -596,13 +614,16 @@ export function TeamPerformancePage() {
-
-
+
)}
{compare && (
-
-
Cycle compare
+
{compare.prior
? `${compare.prior.cycleName} → ${compare.current.cycleName}`
@@ -675,7 +696,7 @@ export function TeamPerformancePage() {
) : (
Add another cycle with ratings to unlock compare.
)}
-
+
)}
>
diff --git a/apps/ui/src/pages/WritingDeskPage.tsx b/apps/ui/src/pages/WritingDeskPage.tsx
index 27acf3d..12b0832 100644
--- a/apps/ui/src/pages/WritingDeskPage.tsx
+++ b/apps/ui/src/pages/WritingDeskPage.tsx
@@ -5,7 +5,7 @@ import { COMPETENCY_CATALOG, MIDDLE_BAND, competencyLabel } from "@prm/shared";
import { api, getToken, apiUrl } from "../lib/api";
import { estimateThenConfirm } from "../lib/aiEstimate";
import { openHtmlExport } from "../lib/exports";
-import { NextStep, PageHeader } from "../components/PageChrome";
+import { NextStep, PageHeader, Section } from "../components/PageChrome";
import { PageSectionsLayout, type SectionNavItem } from "../components/PageSectionsLayout";
import { DeliveryRollupPanel } from "../components/DeliveryRollupPanel";
@@ -487,9 +487,12 @@ export function WritingDeskPage() {
{hasOutOfWindow && outOfWindowTray && (
-
-
Out of window
-
Evidence dated outside the cycle window — still citable.
+
{outOfWindowTray.achievements.map((a) => (
@@ -508,69 +511,75 @@ export function WritingDeskPage() {
))}
-
+
)}
{personId ?
: null}
-
-
-
Evidence digest
- {
- const ok = await estimateThenConfirm(
- "evidence_digest",
- { cycleId, personId },
- "Generate evidence digest",
- );
- if (!ok) return;
- const res = await api<{ outputText: string; citeSuggestions?: DeliveryCiteSuggestion[] }>(
- "/api/ai/digest",
- {
+
+ {
+ const ok = await estimateThenConfirm(
+ "evidence_digest",
+ { cycleId, personId },
+ "Generate evidence digest",
+ );
+ if (!ok) return;
+ const res = await api<{ outputText: string; citeSuggestions?: DeliveryCiteSuggestion[] }>(
+ "/api/ai/digest",
+ {
+ method: "POST",
+ body: JSON.stringify({ cycleId, personId }),
+ },
+ );
+ setDigest(res.outputText);
+ setCiteSuggestions(res.citeSuggestions ?? []);
+ }}
+ >
+ Generate evidence digest
+
+ {
+ const ok = await estimateThenConfirm(
+ "skip_level_talking_points",
+ { cycleId, personId },
+ "Generate skip-level talking points",
+ );
+ if (!ok) return;
+ const res = await api<{ outputText: string }>("/api/ai/skip-level-talking-points", {
method: "POST",
body: JSON.stringify({ cycleId, personId }),
- },
- );
- setDigest(res.outputText);
- setCiteSuggestions(res.citeSuggestions ?? []);
- }}
- >
- Generate digest
-
- {
- const ok = await estimateThenConfirm(
- "skip_level_talking_points",
- { cycleId, personId },
- "Generate skip-level talking points",
- );
- if (!ok) return;
- const res = await api<{ outputText: string }>("/api/ai/skip-level-talking-points", {
- method: "POST",
- body: JSON.stringify({ cycleId, personId }),
- });
- setSkipLevel(res.outputText);
- }}
- >
- Skip-level talking points
-
-
- void openHtmlExport(
- `/api/exports/director-briefing/${cycleId}?personId=${encodeURIComponent(personId ?? "")}&format=html`,
- )
- }
- >
- Director card
-
-
+ });
+ setSkipLevel(res.outputText);
+ }}
+ >
+ Generate skip-level talking points
+
+
+ void openHtmlExport(
+ `/api/exports/director-briefing/${cycleId}?personId=${encodeURIComponent(personId ?? "")}&format=html`,
+ )
+ }
+ >
+ Open director briefing card
+
+ >
+ }
+ >
{digest ?? "Generate a grounded digest from dossier + imports before drafting."}
{skipLevel && (
@@ -606,9 +615,13 @@ export function WritingDeskPage() {
)}
-
-
-
Current role expectations
+
+
{(packet.currentRole?.expectations ?? []).map((e) => (
@@ -616,10 +629,13 @@ export function WritingDeskPage() {
))}
-
-
-
-
Peer synthesize
+
+
- Roll up themes
+ Synthesize peer themes
-
+ }
+ >
{packet.peerReviews.length
- ? `${packet.peerReviews.length} peer import(s) — synthesize themes instead of reading raw JSON.`
- : "No peer imports yet."}
+ ? `${packet.peerReviews.length} peer import(s) available.`
+ : "No peer imports yet — import peer bundles from Cycles first."}
{peerSynth ??
(packet.peerReviews.length
- ? "Run roll-up to get strength/growth themes (authors stay EM-visible here; hidden in subject packet)."
+ ? "Run synthesize to get strength/growth themes (authors stay EM-visible here; hidden in subject packet)."
: "Import peer bundles from Cycles first.")}
-
+
{packet.priorCycle && (
-
-
Prior cycle — {packet.priorCycle.cycleName}
+
Ended {packet.priorCycle.windowEnd}
{packet.priorCycle.overall != null ? ` · Overall ${packet.priorCycle.overall}/5` : ""}
@@ -676,11 +697,22 @@ export function WritingDeskPage() {
Growth: {packet.priorCycle.growthAreas}
)}
-
+
)}
-
-
Person timeline
+
+ Open person dossier
+
+ ) : undefined
+ }
+ >
{(packet.timeline ?? []).length === 0 ? (
No dossier milestones yet.
) : (
@@ -696,10 +728,14 @@ export function WritingDeskPage() {
))}
)}
-
+
-
-
Imports & dossier
+
Self: {packet.selfReview ? "imported" : "missing"}
@@ -740,11 +776,35 @@ export function WritingDeskPage() {
>
)}
-
+
-
+
diff --git a/apps/ui/src/styles.css b/apps/ui/src/styles.css
index fd8966f..063bb0d 100644
--- a/apps/ui/src/styles.css
+++ b/apps/ui/src/styles.css
@@ -37,8 +37,8 @@
--bg: #0f1419;
--bg-soft: #1a222c;
--ink: #e8eef4;
- --ink-soft: #c5d0db;
- --muted: #8b9aab;
+ --ink-soft: #c9d4e0;
+ --muted: #9aa8b8;
--line: #243040;
--line-strong: #334155;
--panel: #161d26;
@@ -61,7 +61,7 @@
--bg-soft: #e8eef4;
--ink: #0f172a;
--ink-soft: #334155;
- --muted: #64748b;
+ --muted: #475569;
--line: #dde5ee;
--line-strong: #c8d3e0;
--panel: #ffffff;
@@ -713,6 +713,80 @@ strong { color: var(--ink-soft); font-weight: 600; }
}
.next-step-action { display: flex; gap: 0.5rem; flex-wrap: wrap; }
+/* ---------- Section scan chrome (WCAG-oriented) ---------- */
+.section-block {
+ gap: var(--space-4);
+}
+.section-header {
+ display: flex;
+ gap: var(--space-4);
+ align-items: flex-start;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ padding-bottom: var(--space-3);
+ border-bottom: 1px solid var(--line);
+ margin-bottom: 0;
+}
+.section-header-copy {
+ flex: 1;
+ min-width: min(100%, 18rem);
+}
+.section-header-copy h2 {
+ margin: 0 0 var(--space-3);
+ font-size: var(--text-lg);
+ line-height: 1.3;
+}
+.section-guide {
+ margin: 0;
+ display: grid;
+ gap: var(--space-2);
+}
+.section-guide > div {
+ display: grid;
+ grid-template-columns: 3.25rem minmax(0, 1fr);
+ gap: var(--space-2) var(--space-3);
+ align-items: start;
+}
+.section-guide dt {
+ margin: 0;
+ font-size: var(--text-xs);
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--brand-strong);
+ line-height: 1.45;
+ padding-top: 0.1rem;
+}
+.section-guide dd {
+ margin: 0;
+ color: var(--ink-soft);
+ font-size: 0.95rem;
+ line-height: 1.5;
+ max-width: 62ch;
+}
+.section-header-actions {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ align-items: center;
+}
+.section-body {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ min-width: 0;
+}
+.section-body > :first-child { margin-top: 0; }
+.section-body .muted {
+ color: var(--ink-soft);
+}
+
+.page-section-nav-link:focus-visible {
+ outline: none;
+ box-shadow: 0 0 0 3px var(--brand-ring);
+ color: var(--ink);
+}
+
.status-pill {
display: inline-flex;
align-items: center;