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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Performance Review Manager — a local, single-user ("solo EM") pnpm monorepo. S

### Data / running
- Local data lives in `/workspace/data` (gitignored): SQLite DB, encrypted vault, uploaded files. Delete it to reset to a clean state (stop the API first, since it holds the DB handle).
- First app load shows "Create & seed demo team" (seeds an 8-person org). After init it shows "Unlock". Default seed password: `workbench`.
- First app load offers **Empty workspace** or **Demo team** (seeds an 8-person org). After init it shows "Unlock". Default demo password: `workbench`.
- Locking the workspace ("Lock & encrypt") seals the SQLite DB + files to `.enc` at rest; unlocking decrypts them.
- `.session` stores the auth token only (not the AES key). After an API process restart you must unlock again even if the UI still has a token.

Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ pnpm dev
| API | http://127.0.0.1:8787 |
| Seed password | `workbench` |

First load: **Create & seed demo team** (8-person org), then unlock. Between sessions use **Lock & encrypt**.
First load: choose **Empty workspace** or **Demo team** (8-person org, password default `workbench`), then unlock later sessions with your password. Between sessions use **Lock & encrypt**.

Requires **pnpm 10.33.3** (see [`AGENTS.md`](./AGENTS.md) if Corepack hands you pnpm 11).

Expand Down Expand Up @@ -85,7 +85,7 @@ 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 can seed a 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.

![Unlock workbench](./docs/screenshots/01-unlock.png)

Expand Down Expand Up @@ -199,7 +199,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 switch dark / light theme.
**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).

![Settings](./docs/screenshots/14-settings.png)

Expand Down
32 changes: 25 additions & 7 deletions apps/ui/index.html
Original file line number Diff line number Diff line change
@@ -1,26 +1,44 @@
<!doctype html>
<html lang="en" data-theme="dark">
<html lang="en" data-theme="dark" data-color-mode="system" data-accent="teal">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="color-scheme" content="dark light" />
<title>Performance Review Manager</title>
<script>
(function () {
try {
var t = localStorage.getItem("prm_theme");
if (t !== "light" && t !== "dark") t = "dark";
document.documentElement.setAttribute("data-theme", t);
document.documentElement.style.colorScheme = t;
var mode = localStorage.getItem("prm_theme");
if (mode !== "light" && mode !== "dark" && mode !== "system") mode = "system";
var accent = localStorage.getItem("prm_accent");
if (accent !== "teal" && accent !== "ocean" && accent !== "forest" && accent !== "slate") {
accent = "teal";
}
var resolved = mode;
if (mode === "system") {
resolved =
window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
document.documentElement.setAttribute("data-theme", resolved);
document.documentElement.setAttribute("data-color-mode", mode);
document.documentElement.setAttribute("data-accent", accent);
document.documentElement.style.colorScheme = resolved;
} catch (e) {
document.documentElement.setAttribute("data-theme", "dark");
document.documentElement.setAttribute("data-color-mode", "system");
document.documentElement.setAttribute("data-accent", "teal");
document.documentElement.style.colorScheme = "dark";
}
})();
</script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link
href="https://fonts.googleapis.com/css2?family=Source+Sans+3:ital,wght@0,400;0,500;0,600;0,700;1,400&family=IBM+Plex+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
Expand Down
141 changes: 116 additions & 25 deletions apps/ui/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { NavLink, Navigate, Route, Routes } from "react-router-dom";
import { NavLink, Navigate, Route, Routes, useLocation } from "react-router-dom";
import { useEffect, useState } from "react";
import type { WorkspaceStatus } from "@prm/shared";
import { api, getToken, setToken, waitForApi } from "./lib/api";
Expand All @@ -20,10 +20,60 @@ import { SprintPulsePage } from "./pages/SprintPulsePage";
import { SearchPage } from "./pages/SearchPage";
import { BackfillPage } from "./pages/BackfillPage";

const NAV = [
{
label: "Work",
links: [
{ to: "/", end: true, label: "Home" },
{ to: "/team", label: "Team" },
{ to: "/roles", label: "Roles" },
{ to: "/cycles", label: "Cycles" },
{ to: "/promotions", label: "Promotions" },
],
},
{
label: "Review",
links: [
{ to: "/consistency", label: "Consistency" },
{ to: "/team-performance", label: "Team performance" },
{ to: "/sprint-pulse", label: "Sprint pulse" },
{ to: "/search", label: "Search" },
{ to: "/backfill", label: "Backfill" },
],
},
{
label: "Tools",
links: [
{ to: "/chat", label: "AI Chat" },
{ to: "/settings", label: "Settings" },
],
},
] as const;

export function App() {
const [status, setStatus] = useState<WorkspaceStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const { theme, toggle } = useTheme();
const [navOpen, setNavOpen] = useState(false);
const { resolved, toggle } = useTheme();
const location = useLocation();

useEffect(() => {
setNavOpen(false);
}, [location.pathname]);

useEffect(() => {
if (!navOpen) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setNavOpen(false);
}
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = prev;
window.removeEventListener("keydown", onKey);
};
}, [navOpen]);

async function refresh() {
try {
Expand All @@ -45,10 +95,10 @@ export function App() {
type="button"
className="btn ghost theme-toggle"
onClick={toggle}
aria-label={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
aria-label={resolved === "dark" ? "Switch to light mode" : "Switch to dark mode"}
title={resolved === "dark" ? "Switch to light mode" : "Switch to dark mode"}
>
{theme === "dark" ? "Light mode" : "Dark mode"}
{resolved === "dark" ? "Light mode" : "Dark mode"}
</button>
);

Expand All @@ -57,7 +107,6 @@ export function App() {
const logPath = typeof window !== "undefined" ? window.__PRM_DESKTOP_LOG__ : undefined;
const apiBase = typeof window !== "undefined" ? window.__PRM_API_BASE_URL__ : undefined;
async function retryDesktop() {
// Prefer same-origin UI served by the sidecar once it is healthy.
const base = (apiBase || "http://127.0.0.1:8787").replace(/\/$/, "");
try {
const res = await fetch(`${base}/api/health`);
Expand Down Expand Up @@ -90,7 +139,7 @@ export function App() {
)}
<p className="mono">{error}</p>
<button className="btn" onClick={desktop ? retryDesktop : refresh}>Retry</button>
<div style={{ marginTop: "0.75rem" }}>{themeToggle}</div>
<div className="unlock-actions">{themeToggle}</div>
</div>
</div>
);
Expand All @@ -110,26 +159,68 @@ export function App() {
}

return (
<div className="app-shell">
<aside className="sidebar">
<h1 className="brand">PRM</h1>
<div className="brand-sub">
{status.name} · solo EM workbench
{status.encryptedAtRest ? " · vault sealed" : " · vault open"}
<div className={`app-shell${navOpen ? " nav-open" : ""}`}>
<header className="topbar">
<button
type="button"
className="nav-toggle"
aria-label="Open navigation"
aria-expanded={navOpen}
onClick={() => setNavOpen(true)}
>
<span className="nav-toggle-bars" aria-hidden />
</button>
<div className="topbar-brand">PRM</div>
<button
type="button"
className="btn ghost"
onClick={toggle}
aria-label={resolved === "dark" ? "Switch to light mode" : "Switch to dark mode"}
>
{resolved === "dark" ? "Light" : "Dark"}
</button>
</header>

<div
className="nav-backdrop"
role="presentation"
onClick={() => setNavOpen(false)}
/>

<aside className="sidebar" aria-label="Primary">
<div className="sidebar-header">
<div>
<h1 className="brand">PRM</h1>
<div className="brand-sub">
{status.name} · solo EM workbench
{status.encryptedAtRest ? " · vault sealed" : " · vault open"}
</div>
</div>
<button
type="button"
className="nav-close"
aria-label="Close navigation"
onClick={() => setNavOpen(false)}
>
✕
</button>
</div>
<nav className="nav">
<NavLink to="/" end>Home</NavLink>
<NavLink to="/team">Team</NavLink>
<NavLink to="/roles">Roles</NavLink>
<NavLink to="/cycles">Cycles</NavLink>
<NavLink to="/promotions">Promotions</NavLink>
<NavLink to="/consistency">Consistency</NavLink>
<NavLink to="/search">Search</NavLink>
<NavLink to="/team-performance">Team performance</NavLink>
<NavLink to="/sprint-pulse">Sprint pulse</NavLink>
<NavLink to="/backfill">Backfill</NavLink>
<NavLink to="/chat">AI Chat</NavLink>
<NavLink to="/settings">Settings</NavLink>
{NAV.map((section) => (
<div key={section.label}>
<div className="nav-section">{section.label}</div>
{section.links.map((link) => (
<NavLink
key={link.to}
to={link.to}
end={"end" in link ? link.end : undefined}
onClick={() => setNavOpen(false)}
>
{link.label}
</NavLink>
))}
</div>
))}
</nav>
<div className="sidebar-actions">
{themeToggle}
Expand Down
83 changes: 66 additions & 17 deletions apps/ui/src/hooks/useTheme.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,96 @@
import { useEffect, useState } from "react";
import {
ACCENT_EVENT,
ACCENT_KEY,
applyTheme,
getStoredTheme,
isTheme,
setTheme as persistTheme,
getStoredAccent,
getStoredColorMode,
isAccent,
isColorMode,
resolveTheme,
setAccent as persistAccent,
setColorMode as persistColorMode,
THEME_EVENT,
type Theme,
THEME_KEY,
type Accent,
type ColorMode,
type ResolvedTheme,
} from "../lib/theme";

/** Theme preference (defaults dark). Persisted in localStorage and synced across mounts. */
/** Color mode + accent. Persisted in localStorage; system mode follows OS preference. */
export function useTheme() {
const [theme, setThemeState] = useState<Theme>(() => getStoredTheme());
const [colorMode, setColorModeState] = useState<ColorMode>(() => getStoredColorMode());
const [accent, setAccentState] = useState<Accent>(() => getStoredAccent());
const [resolved, setResolved] = useState<ResolvedTheme>(() => resolveTheme(getStoredColorMode()));

useEffect(() => {
applyTheme(theme);
}, [theme]);
applyTheme(colorMode, accent);
setResolved(resolveTheme(colorMode));
}, [colorMode, accent]);

useEffect(() => {
function syncResolved() {
setResolved(resolveTheme(getStoredColorMode()));
applyTheme(getStoredColorMode(), getStoredAccent());
}
function onThemeChange(event: Event) {
const next = (event as CustomEvent<unknown>).detail;
if (isTheme(next)) setThemeState(next);
if (isColorMode(next)) setColorModeState(next);
}
function onAccentChange(event: Event) {
const next = (event as CustomEvent<unknown>).detail;
if (isAccent(next)) setAccentState(next);
}
function onStorage(event: StorageEvent) {
if (event.key !== "prm_theme") return;
if (isTheme(event.newValue)) setThemeState(event.newValue);
else if (event.newValue == null) setThemeState("dark");
if (event.key === THEME_KEY) {
if (isColorMode(event.newValue)) setColorModeState(event.newValue);
else if (event.newValue == null) setColorModeState("system");
}
if (event.key === ACCENT_KEY) {
if (isAccent(event.newValue)) setAccentState(event.newValue);
else if (event.newValue == null) setAccentState("teal");
}
}
const mq = window.matchMedia("(prefers-color-scheme: dark)");
mq.addEventListener("change", syncResolved);
window.addEventListener(THEME_EVENT, onThemeChange);
window.addEventListener(ACCENT_EVENT, onAccentChange);
window.addEventListener("storage", onStorage);
return () => {
mq.removeEventListener("change", syncResolved);
window.removeEventListener(THEME_EVENT, onThemeChange);
window.removeEventListener(ACCENT_EVENT, onAccentChange);
window.removeEventListener("storage", onStorage);
};
}, []);

function setTheme(next: Theme) {
persistTheme(next);
setThemeState(next);
function setColorMode(next: ColorMode) {
persistColorMode(next);
setColorModeState(next);
}

function setAccent(next: Accent) {
persistAccent(next);
setAccentState(next);
}

function setTheme(next: ColorMode) {
setColorMode(next);
}

function toggle() {
setTheme(theme === "dark" ? "light" : "dark");
const next: ColorMode = resolved === "dark" ? "light" : "dark";
setColorMode(next);
}

return { theme, setTheme, toggle };
return {
theme: colorMode,
colorMode,
accent,
resolved,
setTheme,
setColorMode,
setAccent,
toggle,
};
}
Loading