Compare commits

...

13 commits

Author SHA1 Message Date
bb393b421d feat(07-02): update Layout toggle to cycle three themes with next-theme label
Some checks failed
Docker / build-and-push (push) Has been cancelled
- Add THEME_CYCLE map for mocha->tokyo-night->latte->mocha
- Compute nextThemeLabel for descriptive aria-label/title on toggle button
- Update both desktop and mobile toggle button aria-label/title to 'Switch to [theme]'
- Icon logic unchanged: Sun in dark mode, Moon in light mode
2026-03-31 14:20:31 +02:00
9710478d6d feat(07-02): add theme picker section to InstanceGeneralSettings
- Import useTheme, THEME_META, type Theme from ThemeContext
- Add ORDERED_THEMES constant with three theme IDs
- Add theme picker section as first section in General Settings
- Color swatches use inline backgroundColor (hardcoded hex, not CSS vars)
- Active theme highlighted with border-primary bg-primary/10
2026-03-31 14:19:35 +02:00
e647a6fba2 feat(07-01): update index.html flash-prevention script for three themes
- Update theme-color meta tag default from #18181b to #1e1e2e (Catppuccin Mocha)
- Replace binary dark/light script with three-theme handler
- Toggles .dark and .theme-tokyo-night classes before React mounts
- Falls back to catppuccin-mocha for unknown/old localStorage values
- Removes old #18181b hardcoded color constant
2026-03-31 14:16:03 +02:00
560400b187 feat(07-01): extend ThemeContext to support three named themes with THEME_META export
- Expand Theme type to catppuccin-mocha | tokyo-night | catppuccin-latte
- Export THEME_META with label, dark boolean, bg hex, primary hex per theme
- applyTheme toggles .dark and .theme-tokyo-night classes correctly
- toggleTheme cycles all three themes (Mocha -> Tokyo Night -> Latte -> Mocha)
- readStoredTheme falls back to catppuccin-mocha for old localStorage values
- Fix Layout.tsx: replace theme === 'dark' comparison with THEME_META[theme].dark
- Fix MarkdownBody.tsx: replace theme === 'dark' comparisons with THEME_META[theme].dark
2026-03-31 14:15:27 +02:00
f0f65a63dd feat(07-01): replace CSS variable blocks with Catppuccin Mocha, Tokyo Night, and Catppuccin Latte palettes
- Replace :root block with Catppuccin Latte light theme values (#eff1f5 base)
- Replace .dark block with Catppuccin Mocha dark theme values (#1e1e2e base)
- Add .theme-tokyo-night.dark block with Tokyo Night values (#1a1b26 base)
- Remove redundant color-scheme: dark; from scrollbar section (moved into .dark block)
2026-03-31 14:13:58 +02:00
c2434bc67e [nexus] fix(06): resolve verifier gaps — portability fallback, export readme, CLI company descriptions, server error msg 2026-03-31 13:57:50 +02:00
9de10c7161 feat(06-03): TERM-18 grep audit — fix remaining display-zone corporate strings
- ui/src/App.tsx: Create/first company titles and descriptions → VOCAB.company
- ui/src/components/OnboardingWizard.tsx: 3 company display strings → VOCAB
- ui/src/components/Sidebar.tsx: 'Select company' fallback → VOCAB
- ui/src/pages/CliAuth.tsx: 'Requested company' label → VOCAB
- ui/src/pages/AgentDetail.tsx: company library string → VOCAB
- server/src/services/company-portability.ts: 'Imported Company' x2 → 'Imported Workspace'
- cli/src/commands/client/{issue,approval,agent,dashboard,activity}.ts: option descriptions → VOCAB
- cli/src/commands/worktree.ts: error message and option description → VOCAB
- server/src/index.ts: comment cleanup (actual value already 'Owner')
- server/src/services/company-export-readme.ts: comment cleanup (value already 'Project Manager')
2026-03-31 13:45:35 +02:00
bf3215cc87 feat(06-02): replace Select a company empty states + CLI Paperclip strings
- 14 UI pages: all Select a company empty states use VOCAB.company.toLowerCase()
- AgentConfigForm: 3 error throws use VOCAB.company
- AgentDetail: additional Select a company upload error replaced
- CLI run.ts: Starting/Could not locate/failed to start messages use VOCAB.appName
- CLI deployment-auth-check: repairHint uses VOCAB.appName
- CLI agent-jwt-secret-check: repairHint uses VOCAB.appName
- CLI allowed-hostname: restart message uses VOCAB.appName
- Added VOCAB import to all files missing it
2026-03-31 13:32:27 +02:00
31c9fe8671 feat(06-02): replace Paperclip brand + CEO display strings in UI components
- AgentDetail: 10 strings replaced (Paperclip→VOCAB.appName, CEO→VOCAB.ceo, board approval→owner approval)
- RoutineDetail: 8 error messages + select company + secret banner replaced
- DesignGuide: 3 strings replaced (Paperclip, Paperclip App, CEO Agent)
- agent-config-primitives: 3 tooltip strings replaced
- AccountingModelCard, JsonSchemaForm, ProjectProperties, OnboardingWizard: 1 each
- openclaw-gateway/config-fields: 2 strings replaced
- Added VOCAB import to all files missing it
2026-03-31 13:25:46 +02:00
cb5d14d6f8 feat(06-01): fix named terminology straggler requirements (TERM-10 through TERM-17)
- TERM-10: Companies.tsx breadcrumb uses VOCAB.companies, loading/delete text uses VOCAB
- TERM-11: InstanceSettings.tsx adds VOCAB import, uses VOCAB.company/companies
- TERM-12: Costs.tsx adds VOCAB import and SCOPE_LABELS map, replaces hardcoded company strings
- TERM-13: CompanyImport.tsx uses VOCAB.appName, VOCAB.company, VOCAB.board throughout
- TERM-17: IssuesList.tsx (component) title='Board view' -> 'Kanban view'
- Dashboard.tsx: 'awaiting board review' -> 'awaiting owner review'
- CompanySettings.tsx: 'No company selected' uses VOCAB.company
- ReportsToPicker.tsx: adds VOCAB import, default label uses VOCAB.ceo not hardcoded 'CEO'
2026-03-31 13:25:00 +02:00
02282ae926 test(05-01): rewrite onboarding E2E for Nexus single-step wizard
- Replace 4-step upstream flow test with single-step Nexus wizard test
- Assert h1 'Welcome to Nexus' is visible (ONBD-10/ONBD-11)
- Assert no 'Next' button, no 4-step h3 headings (ONBD-11)
- Assert 'Acme Corp', 'Company name', corporate strings absent (ONBD-12)
- Fill root dir input, click 'Get Started', expect /dashboard/ URL
- Verify 'Project Manager' and 'Engineer' agents created via API
2026-03-31 12:58:36 +02:00
aba86d5a7c fix(05-01): switch Vite alias to array syntax with RegExp find pattern
- Replace object alias syntax with array of {find, replacement} entries
- '@' and 'lexical' aliases preserved as string find entries
- OnboardingWizard alias uses RegExp /^\.\/components\/OnboardingWizard$/ find
- RegExp matches raw import specifier from App.tsx in both dev and prod modes
2026-03-31 12:58:01 +02:00
2e7a273687 [nexus] fix: use tsx/cli exports subpath instead of deep import (fixes ERR_PACKAGE_PATH_NOT_EXPORTED) 2026-03-31 12:04:43 +02:00
59 changed files with 414 additions and 351 deletions

View file

@ -4,6 +4,7 @@ import {
readAgentJwtSecretFromEnvFile,
resolveAgentJwtEnvFile,
} from "../config/env.js";
import { VOCAB } from "@paperclipai/branding";
import type { CheckResult } from "./index.js";
export function agentJwtSecretCheck(configPath?: string): CheckResult {
@ -23,7 +24,7 @@ export function agentJwtSecretCheck(configPath?: string): CheckResult {
name: "Agent JWT secret",
status: "warn",
message: `PAPERCLIP_AGENT_JWT_SECRET is present in ${envPath} but not loaded into environment`,
repairHint: `Set the value from ${envPath} in your shell before starting the Paperclip server`,
repairHint: `Set the value from ${envPath} in your shell before starting the ${VOCAB.appName} server`,
};
}

View file

@ -1,4 +1,5 @@
import type { PaperclipConfig } from "../config/schema.js";
import { VOCAB } from "@paperclipai/branding";
import type { CheckResult } from "./index.js";
function isLoopbackHost(host: string) {
@ -37,7 +38,7 @@ export function deploymentAuthCheck(config: PaperclipConfig): CheckResult {
status: "fail",
message: "authenticated mode requires BETTER_AUTH_SECRET (or PAPERCLIP_AGENT_JWT_SECRET)",
canRepair: false,
repairHint: "Set BETTER_AUTH_SECRET before starting Paperclip",
repairHint: `Set BETTER_AUTH_SECRET before starting ${VOCAB.appName}`,
};
}

View file

@ -1,4 +1,5 @@
import * as p from "@clack/prompts";
import { VOCAB } from "@paperclipai/branding";
import pc from "picocolors";
import { normalizeHostnameInput } from "../config/hostnames.js";
import { readConfig, resolveConfigPath, writeConfig } from "../config/store.js";
@ -27,7 +28,7 @@ export async function addAllowedHostname(host: string, opts: { config?: string }
} else {
p.log.success(`Added allowed hostname: ${pc.cyan(normalized)}`);
p.log.message(
pc.dim("Restart the Paperclip server for this change to take effect."),
pc.dim(`Restart the ${VOCAB.appName} server for this change to take effect.`),
);
}

View file

@ -1,4 +1,5 @@
import { Command } from "commander";
import { VOCAB } from "@paperclipai/branding";
import type { ActivityEvent } from "@paperclipai/shared";
import {
addCommonClientOptions,
@ -22,8 +23,8 @@ export function registerActivityCommands(program: Command): void {
addCommonClientOptions(
activity
.command("list")
.description("List company activity log entries")
.requiredOption("-C, --company-id <id>", "Company ID")
.description(`List ${VOCAB.company.toLowerCase()} activity log entries`)
.requiredOption("-C, --company-id <id>", `${VOCAB.company} ID`)
.option("--agent-id <id>", "Filter by agent ID")
.option("--entity-type <type>", "Filter by entity type")
.option("--entity-id <id>", "Filter by entity ID")

View file

@ -1,4 +1,5 @@
import { Command } from "commander";
import { VOCAB } from "@paperclipai/branding";
import type { Agent } from "@paperclipai/shared";
import {
removeMaintainerOnlySkillSymlinks,
@ -162,8 +163,8 @@ export function registerAgentCommands(program: Command): void {
addCommonClientOptions(
agent
.command("list")
.description("List agents for a company")
.requiredOption("-C, --company-id <id>", "Company ID")
.description(`List agents for a ${VOCAB.company.toLowerCase()}`)
.requiredOption("-C, --company-id <id>", `${VOCAB.company} ID`)
.action(async (opts: AgentListOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
@ -222,7 +223,7 @@ export function registerAgentCommands(program: Command): void {
"Create an agent API key, install local Paperclip skills for Codex/Claude, and print shell exports",
)
.argument("<agentRef>", "Agent ID or shortname/url-key")
.requiredOption("-C, --company-id <id>", "Company ID")
.requiredOption("-C, --company-id <id>", `${VOCAB.company} ID`)
.option("--key-name <name>", "API key label", "local-cli")
.option(
"--no-install-skills",

View file

@ -1,4 +1,5 @@
import { Command } from "commander";
import { VOCAB } from "@paperclipai/branding";
import {
createApprovalSchema,
requestApprovalRevisionSchema,
@ -48,8 +49,8 @@ export function registerApprovalCommands(program: Command): void {
addCommonClientOptions(
approval
.command("list")
.description("List approvals for a company")
.requiredOption("-C, --company-id <id>", "Company ID")
.description(`List approvals for a ${VOCAB.company.toLowerCase()}`)
.requiredOption("-C, --company-id <id>", `${VOCAB.company} ID`)
.option("--status <status>", "Status filter")
.action(async (opts: ApprovalListOptions) => {
try {
@ -110,7 +111,7 @@ export function registerApprovalCommands(program: Command): void {
approval
.command("create")
.description("Create an approval request")
.requiredOption("-C, --company-id <id>", "Company ID")
.requiredOption("-C, --company-id <id>", `${VOCAB.company} ID`)
.requiredOption("--type <type>", "Approval type (hire_agent|approve_ceo_strategy)")
.requiredOption("--payload <json>", "Approval payload as JSON object")
.option("--requested-by-agent-id <id>", "Requesting agent ID")

View file

@ -83,7 +83,7 @@ const IMPORT_INCLUDE_OPTIONS: Array<{
{ value: "projects", label: "Projects", hint: "projects and workspace metadata" },
{ value: "issues", label: "Tasks", hint: "tasks and recurring routines" },
{ value: "agents", label: "Agents", hint: "agent records and org structure" },
{ value: "skills", label: "Skills", hint: "company skill packages and references" },
{ value: "skills", label: "Skills", hint: `${VOCAB.company.toLowerCase()} skill packages and references` }, // [nexus]
];
const IMPORT_PREVIEW_SAMPLE_LIMIT = 6;
@ -1046,7 +1046,7 @@ export function registerCompanyCommands(program: Command): void {
addCommonClientOptions(
company
.command("list")
.description("List companies")
.description(`List ${VOCAB.companies.toLowerCase()}`) // [nexus]
.action(async (opts: CompanyCommandOptions) => {
try {
const ctx = resolveCommandContext(opts);
@ -1081,8 +1081,8 @@ export function registerCompanyCommands(program: Command): void {
addCommonClientOptions(
company
.command("get")
.description("Get one company")
.argument("<companyId>", "Company ID")
.description(`Get one ${VOCAB.company.toLowerCase()}`) // [nexus]
.argument("<companyId>", `${VOCAB.company} ID`) // [nexus]
.action(async (companyId: string, opts: CompanyCommandOptions) => {
try {
const ctx = resolveCommandContext(opts);
@ -1097,8 +1097,8 @@ export function registerCompanyCommands(program: Command): void {
addCommonClientOptions(
company
.command("export")
.description("Export a company into a portable markdown package")
.argument("<companyId>", "Company ID")
.description(`Export a ${VOCAB.company.toLowerCase()} into a portable markdown package`) // [nexus]
.argument("<companyId>", `${VOCAB.company} ID`) // [nexus]
.requiredOption("--out <path>", "Output directory")
.option("--include <values>", "Comma-separated include set: company,agents,projects,issues,tasks,skills", "company,agents")
.option("--skills <values>", "Comma-separated skill slugs/keys to export")
@ -1373,8 +1373,8 @@ export function registerCompanyCommands(program: Command): void {
addCommonClientOptions(
company
.command("delete")
.description("Delete a company by ID or shortname/prefix (destructive)")
.argument("<selector>", "Company ID or issue prefix (for example PAP)")
.description(`Delete a ${VOCAB.company.toLowerCase()} by ID or shortname/prefix (destructive)`) // [nexus]
.argument("<selector>", `${VOCAB.company} ID or issue prefix (for example PAP)`) // [nexus]
.option(
"--by <mode>",
"Selector mode: auto | id | prefix",
@ -1383,7 +1383,7 @@ export function registerCompanyCommands(program: Command): void {
.option("--yes", "Required safety flag to confirm destructive action", false)
.option(
"--confirm <value>",
"Required safety value: target company ID or shortname/prefix",
`Required safety value: target ${VOCAB.company.toLowerCase()} ID or shortname/prefix`, // [nexus]
)
.action(async (selector: string, opts: CompanyDeleteOptions) => {
try {
@ -1425,7 +1425,7 @@ export function registerCompanyCommands(program: Command): void {
} catch (error) {
if (error instanceof ApiRequestError && error.status === 403 && error.message.includes("Board access required")) {
throw new Error(
"Board access is required to resolve companies across the instance. Use a company ID/prefix for your current company, or run with board authentication.",
`${VOCAB.board} access is required to resolve ${VOCAB.companies.toLowerCase()} across the instance. Use a ${VOCAB.company.toLowerCase()} ID/prefix for your current ${VOCAB.company.toLowerCase()}, or run with ${VOCAB.board.toLowerCase()} authentication.`, // [nexus]
);
}
throw error;

View file

@ -1,4 +1,5 @@
import { Command } from "commander";
import { VOCAB } from "@paperclipai/branding";
import type { DashboardSummary } from "@paperclipai/shared";
import {
addCommonClientOptions,
@ -18,8 +19,8 @@ export function registerDashboardCommands(program: Command): void {
addCommonClientOptions(
dashboard
.command("get")
.description("Get dashboard summary for a company")
.requiredOption("-C, --company-id <id>", "Company ID")
.description(`Get dashboard summary for a ${VOCAB.company.toLowerCase()}`)
.requiredOption("-C, --company-id <id>", `${VOCAB.company} ID`)
.action(async (opts: DashboardGetOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });

View file

@ -1,4 +1,5 @@
import { Command } from "commander";
import { VOCAB } from "@paperclipai/branding";
import {
addIssueCommentSchema,
checkoutIssueSchema,
@ -67,8 +68,8 @@ export function registerIssueCommands(program: Command): void {
addCommonClientOptions(
issue
.command("list")
.description("List issues for a company")
.option("-C, --company-id <id>", "Company ID")
.description(`List issues for a ${VOCAB.company.toLowerCase()}`)
.option("-C, --company-id <id>", `${VOCAB.company} ID`)
.option("--status <csv>", "Comma-separated statuses")
.option("--assignee-agent-id <id>", "Filter by assignee agent ID")
.option("--project-id <id>", "Filter by project ID")
@ -136,7 +137,7 @@ export function registerIssueCommands(program: Command): void {
issue
.command("create")
.description("Create an issue")
.requiredOption("-C, --company-id <id>", "Company ID")
.requiredOption("-C, --company-id <id>", `${VOCAB.company} ID`)
.requiredOption("--title <title>", "Issue title")
.option("--description <text>", "Issue description")
.option("--status <status>", "Issue status")

View file

@ -1,4 +1,5 @@
import fs from "node:fs";
import { VOCAB } from "@paperclipai/branding";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import * as p from "@clack/prompts";
@ -78,7 +79,7 @@ export async function runCommand(opts: RunOptions): Promise<void> {
process.exit(1);
}
p.log.step("Starting Paperclip server...");
p.log.step(`Starting ${VOCAB.appName} server...`);
const startedServer = await importServerEntry();
if (shouldGenerateBootstrapInviteAfterStart(config)) {
@ -165,13 +166,13 @@ async function importServerEntry(): Promise<StartedServer> {
const missingServerEntrypoint = !missingSpecifier || missingSpecifier === "@paperclipai/server";
if (isModuleNotFoundError(err) && missingServerEntrypoint) {
throw new Error(
`Could not locate a Paperclip server entrypoint.\n` +
`Could not locate a ${VOCAB.appName} server entrypoint.\n` +
`Tried: ${devEntry}, @paperclipai/server\n` +
`${formatError(err)}`,
);
}
throw new Error(
`Paperclip server failed to start.\n` +
`${VOCAB.appName} server failed to start.\n` +
`${formatError(err)}`,
);
}

View file

@ -77,6 +77,7 @@ import {
type PlannedIssueDocumentMerge,
type PlannedIssueInsert,
} from "./worktree-merge-history-lib.js";
import { VOCAB } from "@paperclipai/branding";
type WorktreeInitOptions = {
name?: string;
@ -1538,7 +1539,7 @@ async function resolveMergeCompany(input: {
}
if (shared.length === 0) {
throw new Error("Source and target databases do not share a company id. Pass --company explicitly once both sides match.");
throw new Error(`Source and target databases do not share a ${VOCAB.company.toLowerCase()} id. Pass --company explicitly once both sides match.`);
}
const options = shared
@ -2644,7 +2645,7 @@ export function registerWorktreeCommands(program: Command): void {
.argument("[source]", "Optional source worktree path, directory name, or branch name (back-compat alias for --from)")
.option("--from <worktree>", "Source worktree path, directory name, branch name, or current")
.option("--to <worktree>", "Target worktree path, directory name, branch name, or current (defaults to current)")
.option("--company <id-or-prefix>", "Shared company id or issue prefix inside the chosen source/target instances")
.option("--company <id-or-prefix>", `Shared ${VOCAB.company.toLowerCase()} id or issue prefix inside the chosen source/target instances`)
.option("--scope <items>", "Comma-separated scopes to import (issues, comments)", "issues,comments")
.option("--apply", "Apply the import after previewing the plan", false)
.option("--dry", "Preview only and do not import anything", false)

View file

@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
import { resolveServerDevWatchIgnorePaths } from "../src/dev-watch-ignore.ts";
const require = createRequire(import.meta.url);
const tsxCliPath = require.resolve("tsx/dist/cli.mjs");
const tsxCliPath = require.resolve("tsx/cli"); // [nexus] use exports map subpath, not deep import
const serverRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const ignoreArgs = resolveServerDevWatchIgnorePaths(serverRoot).flatMap((ignorePath) => ["--exclude", ignorePath]);

View file

@ -185,7 +185,7 @@ export async function startServer(): Promise<StartedServer> {
const LOCAL_BOARD_USER_ID = "local-board";
const LOCAL_BOARD_USER_EMAIL = "local@paperclip.local";
const LOCAL_BOARD_USER_NAME = "Owner"; // [nexus] was: "Board"
const LOCAL_BOARD_USER_NAME = "Owner"; // [nexus] renamed from Board
async function ensureLocalTrustedBoardPrincipal(db: any): Promise<void> {
const now = new Date();
@ -757,7 +757,7 @@ function isMainModule(metaUrl: string): boolean {
if (isMainModule(import.meta.url)) {
void startServer().catch((err) => {
logger.error({ err }, "Paperclip server failed to start");
logger.error({ err }, "Nexus server failed to start"); // [nexus]
process.exit(1);
});
}

View file

@ -4,7 +4,7 @@
import type { CompanyPortabilityManifest } from "@paperclipai/shared";
const ROLE_LABELS: Record<string, string> = {
ceo: "Project Manager", // [nexus] was: "CEO"
ceo: "Project Manager", // [nexus] renamed from CEO
cto: "CTO",
cmo: "CMO",
cfo: "CFO",
@ -96,7 +96,7 @@ export function generateReadme(
// What's Inside table
lines.push("## What's Inside");
lines.push("");
lines.push("> This is an [Agent Company](https://agentcompanies.io) package from [Paperclip](https://paperclip.ing)");
lines.push("> This is an Agent Workspace package from Nexus"); // [nexus]
lines.push("");
const counts: Array<[string, number]> = [];
@ -157,15 +157,15 @@ export function generateReadme(
lines.push("## Getting Started");
lines.push("");
lines.push("```bash");
lines.push("pnpm paperclipai company import this-github-url-or-folder");
lines.push("pnpm paperclipai company import this-github-url-or-folder"); // [nexus] CLI command unchanged (code-zone)
lines.push("```");
lines.push("");
lines.push("See [Paperclip](https://paperclip.ing) for more information.");
lines.push("See the Nexus documentation for more information."); // [nexus]
lines.push("");
// Footer
lines.push("---");
lines.push(`Exported from [Paperclip](https://paperclip.ing) on ${new Date().toISOString().split("T")[0]}`);
lines.push(`Exported from Nexus on ${new Date().toISOString().split("T")[0]}`); // [nexus]
lines.push("");
return lines.join("\n");

View file

@ -2251,7 +2251,7 @@ function buildManifestFromPackageFiles(
const companyName =
asString(companyFrontmatter.name)
?? opts?.sourceLabel?.companyName
?? "Imported Company";
?? "Imported Workspace";
const companySlug =
asString(companyFrontmatter.slug)
?? normalizeAgentUrlKey(companyName)
@ -3726,7 +3726,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
asString(input.target.newCompanyName) ??
sourceManifest.company?.name ??
sourceManifest.source?.companyName ??
"Imported Company";
"Imported Workspace"; // [nexus]
const created = await companies.create({
name: companyName,
description: include.company ? (sourceManifest.company?.description ?? null) : null,

View file

@ -1,142 +1,65 @@
import { test, expect } from "@playwright/test";
/**
* E2E: Onboarding wizard flow (skip_llm mode).
* E2E: Nexus onboarding wizard single-step root directory flow.
*
* Walks through the 4-step OnboardingWizard:
* Step 1 Name your company
* Step 2 Create your first agent (adapter selection + config)
* Step 3 Give it something to do (task creation)
* Step 4 Ready to launch (summary + open issue)
*
* By default this runs in skip_llm mode: we do NOT assert that an LLM
* heartbeat fires. Set PAPERCLIP_E2E_SKIP_LLM=false to enable LLM-dependent
* assertions (requires a valid ANTHROPIC_API_KEY).
* Verifies:
* ONBD-10 Vite alias intercepts, NexusOnboardingWizard renders
* ONBD-11 Single root-directory input only, no multi-step flow
* ONBD-12 No corporate placeholder text visible
*/
const SKIP_LLM = process.env.PAPERCLIP_E2E_SKIP_LLM !== "false";
const COMPANY_NAME = `E2E-Test-${Date.now()}`;
const AGENT_NAME = "CEO";
const TASK_TITLE = "E2E test task";
test.describe("Onboarding wizard", () => {
test("completes full wizard flow", async ({ page }) => {
test.describe("Nexus onboarding wizard", () => {
test("single-step flow: root dir input, no corporate strings, lands on dashboard", async ({ page }) => {
await page.goto("/");
const wizardHeading = page.locator("h3", { hasText: "Name your company" });
const newCompanyBtn = page.getByRole("button", { name: "New Company" });
// ONBD-10 + ONBD-11: Nexus wizard renders with single-step heading
const heading = page.locator("h1", { hasText: "Welcome to Nexus" });
await expect(heading).toBeVisible({ timeout: 15_000 });
await expect(
wizardHeading.or(newCompanyBtn)
).toBeVisible({ timeout: 15_000 });
// ONBD-11: Only a root directory input — no multi-step navigation
await expect(page.getByRole("button", { name: "Next" })).toHaveCount(0);
await expect(page.locator("h3", { hasText: "Name your company" })).toHaveCount(0);
await expect(page.locator("h3", { hasText: "Create your first agent" })).toHaveCount(0);
await expect(page.locator("h3", { hasText: "Give it something to do" })).toHaveCount(0);
await expect(page.locator("h3", { hasText: "Ready to launch" })).toHaveCount(0);
if (await newCompanyBtn.isVisible()) {
await newCompanyBtn.click();
}
// ONBD-12: No corporate placeholder text
await expect(page.getByText("Acme Corp")).toHaveCount(0);
await expect(page.getByText("Company name")).toHaveCount(0);
await expect(page.getByText("What is this company trying to achieve?")).toHaveCount(0);
await expect(wizardHeading).toBeVisible({ timeout: 5_000 });
// Fill root directory and submit
const rootDirInput = page.locator('input[placeholder="~/projects/my-project"]');
await expect(rootDirInput).toBeVisible();
await rootDirInput.fill("/tmp/nexus-e2e-test");
const companyNameInput = page.locator('input[placeholder="Acme Corp"]');
await companyNameInput.fill(COMPANY_NAME);
await page.getByRole("button", { name: "Get Started" }).click();
const nextButton = page.getByRole("button", { name: "Next" });
await nextButton.click();
await expect(
page.locator("h3", { hasText: "Create your first agent" })
).toBeVisible({ timeout: 10_000 });
const agentNameInput = page.locator('input[placeholder="CEO"]');
await expect(agentNameInput).toHaveValue(AGENT_NAME);
await expect(
page.locator("button", { hasText: "Claude Code" }).locator("..")
).toBeVisible();
await page.getByRole("button", { name: "More Agent Adapter Types" }).click();
await expect(page.getByRole("button", { name: "Process" })).toHaveCount(0);
await page.getByRole("button", { name: "Next" }).click();
await expect(
page.locator("h3", { hasText: "Give it something to do" })
).toBeVisible({ timeout: 10_000 });
const taskTitleInput = page.locator(
'input[placeholder="e.g. Research competitor pricing"]'
);
await taskTitleInput.clear();
await taskTitleInput.fill(TASK_TITLE);
await page.getByRole("button", { name: "Next" }).click();
await expect(
page.locator("h3", { hasText: "Ready to launch" })
).toBeVisible({ timeout: 10_000 });
await expect(page.locator("text=" + COMPANY_NAME)).toBeVisible();
await expect(page.locator("text=" + AGENT_NAME)).toBeVisible();
await expect(page.locator("text=" + TASK_TITLE)).toBeVisible();
await page.getByRole("button", { name: "Create & Open Issue" }).click();
await expect(page).toHaveURL(/\/issues\//, { timeout: 10_000 });
// Should navigate to dashboard
await expect(page).toHaveURL(/\/dashboard/, { timeout: 30_000 });
// Verify workspace and agents created via API
const baseUrl = page.url().split("/").slice(0, 3).join("/");
const companiesRes = await page.request.get(`${baseUrl}/api/companies`);
expect(companiesRes.ok()).toBe(true);
const companies = await companiesRes.json();
const company = companies.find(
(c: { name: string }) => c.name === COMPANY_NAME
);
expect(company).toBeTruthy();
expect(companies.length).toBeGreaterThan(0);
const companyId = companies[0].id;
const agentsRes = await page.request.get(
`${baseUrl}/api/companies/${company.id}/agents`
`${baseUrl}/api/companies/${companyId}/agents`
);
expect(agentsRes.ok()).toBe(true);
const agents = await agentsRes.json();
const ceoAgent = agents.find(
(a: { name: string }) => a.name === AGENT_NAME
);
expect(ceoAgent).toBeTruthy();
expect(ceoAgent.role).toBe("ceo");
expect(ceoAgent.adapterType).not.toBe("process");
const instructionsBundleRes = await page.request.get(
`${baseUrl}/api/agents/${ceoAgent.id}/instructions-bundle?companyId=${company.id}`
);
expect(instructionsBundleRes.ok()).toBe(true);
const instructionsBundle = await instructionsBundleRes.json();
// PM agent (role: ceo, name: "Project Manager") and Engineer created
expect(
instructionsBundle.files.map((file: { path: string }) => file.path).sort()
).toEqual(["AGENTS.md", "HEARTBEAT.md", "SOUL.md", "TOOLS.md"]);
const issuesRes = await page.request.get(
`${baseUrl}/api/companies/${company.id}/issues`
);
expect(issuesRes.ok()).toBe(true);
const issues = await issuesRes.json();
const task = issues.find(
(i: { title: string }) => i.title === TASK_TITLE
);
expect(task).toBeTruthy();
expect(task.assigneeAgentId).toBe(ceoAgent.id);
expect(task.description).toContain(
"You are the CEO. You set the direction for the company."
);
expect(task.description).not.toContain("github.com/paperclipai/companies");
if (!SKIP_LLM) {
await expect(async () => {
const res = await page.request.get(
`${baseUrl}/api/issues/${task.id}`
);
const issue = await res.json();
expect(["in_progress", "done"]).toContain(issue.status);
}).toPass({ timeout: 120_000, intervals: [5_000] });
}
agents.some((a: { name: string }) => a.name === "Project Manager")
).toBe(true);
expect(
agents.some((a: { name: string }) => a.name === "Engineer")
).toBe(true);
});
});

View file

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#18181b" />
<meta name="theme-color" content="#1e1e2e" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Nexus" />
@ -21,17 +21,18 @@
<script>
(() => {
const key = "paperclip.theme";
const darkThemeColor = "#18181b";
const lightThemeColor = "#ffffff";
const VALID = ["catppuccin-mocha", "tokyo-night", "catppuccin-latte"];
try {
const stored = window.localStorage.getItem(key);
const theme = stored === "light" || stored === "dark" ? stored : "dark";
const isDark = theme === "dark";
const theme = VALID.includes(stored) ? stored : "catppuccin-mocha";
const isDark = theme !== "catppuccin-latte";
document.documentElement.classList.toggle("dark", isDark);
document.documentElement.classList.toggle("theme-tokyo-night", theme === "tokyo-night");
document.documentElement.style.colorScheme = isDark ? "dark" : "light";
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
if (themeColorMeta) {
themeColorMeta.setAttribute("content", isDark ? darkThemeColor : lightThemeColor);
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) {
const bg = { "catppuccin-mocha": "#1e1e2e", "tokyo-night": "#1a1b26", "catppuccin-latte": "#eff1f5" };
meta.setAttribute("content", bg[theme] || "#1e1e2e");
}
} catch {
document.documentElement.classList.add("dark");

View file

@ -199,13 +199,13 @@ function OnboardingRoutePage() {
const title = matchedCompany
? `Add another agent to ${matchedCompany.name}`
: companies.length > 0
? "Create another company"
: "Create your first company";
? `Create another ${VOCAB.company.toLowerCase()}`
: `Create your first ${VOCAB.company.toLowerCase()}`;
const description = matchedCompany
? "Run onboarding again to add an agent and a starter task for this company."
? `Run onboarding again to add an agent and a starter task for this ${VOCAB.company.toLowerCase()}.`
: companies.length > 0
? "Run onboarding again to create another company and seed its first agent."
: "Get started by creating a company and your first agent.";
? `Run onboarding again to create another ${VOCAB.company.toLowerCase()} and seed its first agent.`
: `Get started by creating a ${VOCAB.company.toLowerCase()} and your first agent.`;
return (
<div className="mx-auto max-w-xl py-10">
@ -287,12 +287,12 @@ function NoCompaniesStartPage() {
return (
<div className="mx-auto max-w-xl py-10">
<div className="rounded-lg border border-border bg-card p-6">
<h1 className="text-xl font-semibold">Create your first company</h1>
<h1 className="text-xl font-semibold">{`Create your first ${VOCAB.company.toLowerCase()}`}</h1>
<p className="mt-2 text-sm text-muted-foreground">
Get started by creating a company.
{`Get started by creating a ${VOCAB.company.toLowerCase()}.`}
</p>
<div className="mt-4">
<Button onClick={() => openOnboarding()}>New Company</Button>
<Button onClick={() => openOnboarding()}>{`New ${VOCAB.company}`}</Button>
</div>
</div>
</div>

View file

@ -1,4 +1,5 @@
import { useState } from "react";
import { VOCAB } from "@paperclipai/branding";
import { Eye, EyeOff } from "lucide-react";
import type { AdapterConfigFieldsProps } from "../types";
import {
@ -134,7 +135,7 @@ export function OpenClawGatewayConfigFields({
{!isCreate && (
<>
<Field label="Paperclip API URL override">
<Field label={`${VOCAB.appName} API URL override`}>
<DraftInput
value={
eff(
@ -226,7 +227,7 @@ export function OpenClawGatewayConfigFields({
<Field label="Device auth">
<div className="text-xs text-muted-foreground leading-relaxed">
Always enabled for gateway agents. Paperclip persists a device key during onboarding so pairing approvals
{`Always enabled for gateway agents. ${VOCAB.appName} persists a device key during onboarding so pairing approvals`}
remain stable across runs.
</div>
</Field>

View file

@ -1,4 +1,5 @@
import { Database, Gauge, ReceiptText } from "lucide-react";
import { VOCAB } from "@paperclipai/branding";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
const SURFACES = [
@ -34,7 +35,7 @@ export function AccountingModelCard() {
Accounting model
</CardTitle>
<CardDescription className="max-w-2xl text-sm leading-6">
Paperclip now separates request-level inference usage from account-level finance events.
{`${VOCAB.appName} now separates request-level inference usage from account-level finance events.`}
That keeps provider reporting honest when the biller is OpenRouter, Cloudflare, Bedrock, or another intermediary.
</CardDescription>
</CardHeader>

View file

@ -1,4 +1,5 @@
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { VOCAB } from "@paperclipai/branding";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared";
import type {
@ -186,7 +187,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const createSecret = useMutation({
mutationFn: (input: { name: string; value: string }) => {
if (!selectedCompanyId) throw new Error("Select a company to create secrets");
if (!selectedCompanyId) throw new Error(`Select a ${VOCAB.company.toLowerCase()} to create secrets`);
return secretsApi.create(selectedCompanyId, input);
},
onSuccess: () => {
@ -197,7 +198,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const uploadMarkdownImage = useMutation({
mutationFn: async ({ file, namespace }: { file: File; namespace: string }) => {
if (!selectedCompanyId) throw new Error("Select a company to upload images");
if (!selectedCompanyId) throw new Error(`Select a ${VOCAB.company.toLowerCase()} to upload images`);
return assetsApi.uploadImage(selectedCompanyId, file, namespace);
},
});
@ -359,7 +360,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const testEnvironment = useMutation({
mutationFn: async () => {
if (!selectedCompanyId) {
throw new Error("Select a company to test adapter environment");
throw new Error(`Select a ${VOCAB.company.toLowerCase()} to test adapter environment`);
}
return agentsApi.testEnvironment(selectedCompanyId, adapterType, {
adapterConfig: buildAdapterConfigForTest(),

View file

@ -391,7 +391,7 @@ export function IssuesList({
<button
className={`p-1.5 transition-colors ${viewState.viewMode === "board" ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground"}`}
onClick={() => updateView({ viewMode: "board" })}
title="Board view"
title="Kanban view"
>
<Columns3 className="h-3.5 w-3.5" />
</button>

View file

@ -1,4 +1,5 @@
import React, { useCallback, useMemo, useState } from "react";
import { VOCAB } from "@paperclipai/branding";
import {
ChevronDown,
ChevronRight,
@ -494,7 +495,7 @@ const SecretField = React.memo(({
label={label}
description={
description ||
"This secret is stored securely via the Paperclip secret provider."
`This secret is stored securely via the ${VOCAB.appName} secret provider.`
}
required={isRequired}
error={error}

View file

@ -20,7 +20,7 @@ import { useDialog } from "../context/DialogContext";
import { usePanel } from "../context/PanelContext";
import { useCompany } from "../context/CompanyContext";
import { useSidebar } from "../context/SidebarContext";
import { useTheme } from "../context/ThemeContext";
import { useTheme, THEME_META } from "../context/ThemeContext";
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
import { healthApi } from "../api/health";
@ -59,6 +59,12 @@ export function Layout() {
setSelectedCompanyId,
} = useCompany();
const { theme, toggleTheme } = useTheme();
const THEME_CYCLE: Record<string, string> = {
"catppuccin-mocha": "Tokyo Night",
"tokyo-night": "Catppuccin Latte",
"catppuccin-latte": "Catppuccin Mocha",
};
const nextThemeLabel = THEME_CYCLE[theme] ?? "next theme";
const { companyPrefix } = useParams<{ companyPrefix: string }>();
const navigate = useNavigate();
const location = useLocation();
@ -67,7 +73,7 @@ export function Layout() {
const lastMainScrollTop = useRef(0);
const [mobileNavVisible, setMobileNavVisible] = useState(true);
const [instanceSettingsTarget, setInstanceSettingsTarget] = useState<string>(() => readRememberedInstanceSettingsPath());
const nextTheme = theme === "dark" ? "light" : "dark";
const isDarkTheme = THEME_META[theme].dark;
const matchedCompany = useMemo(() => {
if (!companyPrefix) return null;
const requestedPrefix = companyPrefix.toUpperCase();
@ -331,10 +337,10 @@ export function Layout() {
size="icon-sm"
className="text-muted-foreground shrink-0"
onClick={toggleTheme}
aria-label={`Switch to ${nextTheme} mode`}
title={`Switch to ${nextTheme} mode`}
aria-label={`Switch to ${nextThemeLabel}`}
title={`Switch to ${nextThemeLabel}`}
>
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
{isDarkTheme ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
</div>
</div>
@ -389,10 +395,10 @@ export function Layout() {
size="icon-sm"
className="text-muted-foreground shrink-0"
onClick={toggleTheme}
aria-label={`Switch to ${nextTheme} mode`}
title={`Switch to ${nextTheme} mode`}
aria-label={`Switch to ${nextThemeLabel}`}
title={`Switch to ${nextThemeLabel}`}
>
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
{isDarkTheme ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
</div>
</div>

View file

@ -2,7 +2,7 @@ import { isValidElement, useEffect, useId, useState, type ReactNode } from "reac
import Markdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { cn } from "../lib/utils";
import { useTheme } from "../context/ThemeContext";
import { useTheme, THEME_META } from "../context/ThemeContext";
import { mentionChipInlineStyle, parseMentionChipHref } from "../lib/mention-chips";
interface MarkdownBodyProps {
@ -97,7 +97,7 @@ export function MarkdownBody({ children, className, resolveImageSrc }: MarkdownB
pre: ({ node: _node, children: preChildren, ...preProps }) => {
const mermaidSource = extractMermaidSource(preChildren);
if (mermaidSource) {
return <MermaidDiagramBlock source={mermaidSource} darkMode={theme === "dark"} />;
return <MermaidDiagramBlock source={mermaidSource} darkMode={THEME_META[theme].dark} />;
}
return <pre {...preProps}>{preChildren}</pre>;
},
@ -140,7 +140,7 @@ export function MarkdownBody({ children, className, resolveImageSrc }: MarkdownB
<div
className={cn(
"paperclip-markdown prose prose-sm max-w-none break-words overflow-hidden",
theme === "dark" && "prose-invert",
THEME_META[theme].dark && "prose-invert",
className,
)}
>

View file

@ -351,7 +351,7 @@ export function OnboardingWizard() {
): Promise<AdapterEnvironmentTestResult | null> {
if (!createdCompanyId) {
setAdapterEnvError(
"Create or select a company before testing adapter environment."
`Create or select a ${VOCAB.company.toLowerCase()} before testing adapter environment.`
);
return null;
}
@ -407,7 +407,7 @@ export function OnboardingWizard() {
setStep(2);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create company");
setError(err instanceof Error ? err.message : `Failed to create ${VOCAB.company.toLowerCase()}`);
} finally {
setLoading(false);
}
@ -715,7 +715,7 @@ export function OnboardingWizard() {
</label>
<textarea
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50 resize-none min-h-[60px]"
placeholder="What is this company trying to achieve?"
placeholder={`What is this ${VOCAB.company.toLowerCase()} trying to achieve?`}
value={companyGoal}
onChange={(e) => setCompanyGoal(e.target.value)}
/>
@ -1051,7 +1051,7 @@ export function OnboardingWizard() {
<p className="text-[11px] text-amber-900/90 leading-relaxed">
Claude failed while{" "}
<span className="font-mono">ANTHROPIC_API_KEY</span>{" "}
is set. You can clear it in this CEO adapter config
is set. You can clear it in this {VOCAB.ceo} adapter config
and retry the probe.
</p>
<Button

View file

@ -1,4 +1,5 @@
import { useState } from "react";
import { VOCAB } from "@paperclipai/branding";
import { Link } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Project } from "@paperclipai/shared";
@ -687,7 +688,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
{codebase.effectiveLocalFolder}
</div>
{codebase.origin === "managed_checkout" && (
<div className="text-[11px] text-muted-foreground">Paperclip-managed folder.</div>
<div className="text-[11px] text-muted-foreground">{`${VOCAB.appName}-managed folder.`}</div>
)}
</div>
<div className="flex items-center gap-1">
@ -719,7 +720,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
{hasAdditionalLegacyWorkspaces && (
<div className="text-[11px] text-muted-foreground">
Additional legacy workspace records exist on this project. Paperclip is using the primary workspace as the codebase view.
{`Additional legacy workspace records exist on this project. ${VOCAB.appName} is using the primary workspace as the codebase view.`}
</div>
)}

View file

@ -1,4 +1,5 @@
import { useState } from "react";
import { VOCAB } from "@paperclipai/branding";
import type { Agent } from "@paperclipai/shared";
import {
Popover,
@ -16,7 +17,7 @@ export function ReportsToPicker({
onChange,
disabled = false,
excludeAgentIds = [],
disabledEmptyLabel = "Reports to: N/A (CEO)",
disabledEmptyLabel,
chooseLabel = "Reports to...",
}: {
agents: Agent[];
@ -27,6 +28,7 @@ export function ReportsToPicker({
disabledEmptyLabel?: string;
chooseLabel?: string;
}) {
const label = disabledEmptyLabel ?? `Reports to: N/A (${VOCAB.ceo})`;
const [open, setOpen] = useState(false);
const exclude = new Set(excludeAgentIds);
const rows = agents.filter(
@ -69,7 +71,7 @@ export function ReportsToPicker({
<>
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate">
{disabled ? disabledEmptyLabel : chooseLabel}
{disabled ? label : chooseLabel}
</span>
</>
)}

View file

@ -58,7 +58,7 @@ export function Sidebar() {
/>
)}
<span className="flex-1 text-sm font-bold text-foreground truncate pl-1">
{selectedCompany?.name ?? "Select company"}
{selectedCompany?.name ?? `Select ${VOCAB.company.toLowerCase()}`}
</span>
<Button
variant="ghost"

View file

@ -1,4 +1,5 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { VOCAB } from "@paperclipai/branding";
import {
Tooltip,
TooltipTrigger,
@ -33,7 +34,7 @@ export const help: Record<string, string> = {
dangerouslySkipPermissions: "Run unattended by auto-approving adapter permission prompts when supported.",
dangerouslyBypassSandbox: "Run Codex without sandbox restrictions. Required for filesystem/network access.",
search: "Enable Codex web search capability during runs.",
workspaceStrategy: "How Paperclip should realize an execution workspace for this agent. Keep project_primary for normal cwd execution, or use git_worktree for issue-scoped isolated checkouts.",
workspaceStrategy: `How ${VOCAB.appName} should realize an execution workspace for this agent. Keep project_primary for normal cwd execution, or use git_worktree for issue-scoped isolated checkouts.`,
workspaceBaseRef: "Base git ref used when creating a worktree branch. Leave blank to use the resolved workspace ref or HEAD.",
workspaceBranchTemplate: "Template for naming derived branches. Supports {{issue.identifier}}, {{issue.title}}, {{agent.name}}, {{project.id}}, {{workspace.repoRef}}, and {{slug}}.",
worktreeParentDir: "Directory where derived worktrees should be created. Absolute, ~-prefixed, and repo-relative paths are supported.",
@ -44,8 +45,8 @@ export const help: Record<string, string> = {
args: "Command-line arguments, comma-separated.",
extraArgs: "Extra CLI arguments for local adapters, comma-separated.",
envVars: "Environment variables injected into the adapter process. Use plain values or secret references.",
bootstrapPrompt: "Only sent when Paperclip starts a fresh session. Use this for stable setup guidance that should not be repeated on every heartbeat.",
payloadTemplateJson: "Optional JSON merged into remote adapter request payloads before Paperclip adds its standard wake and workspace fields.",
bootstrapPrompt: `Only sent when ${VOCAB.appName} starts a fresh session. Use this for stable setup guidance that should not be repeated on every heartbeat.`,
payloadTemplateJson: `Optional JSON merged into remote adapter request payloads before ${VOCAB.appName} adds its standard wake and workspace fields.`,
webhookUrl: "The URL that receives POST requests when the agent is invoked.",
heartbeatInterval: "Run this agent automatically on a timer. Useful for periodic tasks like checking for new work.",
intervalSec: "Seconds between automatic heartbeat invocations.",

View file

@ -8,7 +8,16 @@ import {
type ReactNode,
} from "react";
type Theme = "light" | "dark";
export type Theme = "catppuccin-mocha" | "tokyo-night" | "catppuccin-latte";
export const THEME_META: Record<Theme, { label: string; dark: boolean; bg: string; primary: string }> = {
"catppuccin-mocha": { label: "Catppuccin Mocha", dark: true, bg: "#1e1e2e", primary: "#89b4fa" },
"tokyo-night": { label: "Tokyo Night", dark: true, bg: "#1a1b26", primary: "#7aa2f7" },
"catppuccin-latte": { label: "Catppuccin Latte", dark: false, bg: "#eff1f5", primary: "#1e66f5" },
};
const VALID_THEMES: Theme[] = ["catppuccin-mocha", "tokyo-night", "catppuccin-latte"];
const DEFAULT_THEME: Theme = "catppuccin-mocha";
interface ThemeContextValue {
theme: Theme;
@ -17,36 +26,47 @@ interface ThemeContextValue {
}
const THEME_STORAGE_KEY = "paperclip.theme";
const DARK_THEME_COLOR = "#18181b";
const LIGHT_THEME_COLOR = "#ffffff";
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
function resolveThemeFromDocument(): Theme {
if (typeof document === "undefined") return "dark";
return document.documentElement.classList.contains("dark") ? "dark" : "light";
function isValidTheme(value: string | null): value is Theme {
return value !== null && VALID_THEMES.includes(value as Theme);
}
function readStoredTheme(): Theme {
if (typeof document === "undefined") return DEFAULT_THEME;
try {
const stored = localStorage.getItem(THEME_STORAGE_KEY);
return isValidTheme(stored) ? stored : DEFAULT_THEME;
} catch {
return DEFAULT_THEME;
}
}
function applyTheme(theme: Theme) {
if (typeof document === "undefined") return;
const isDark = theme === "dark";
const meta = THEME_META[theme];
const root = document.documentElement;
root.classList.toggle("dark", isDark);
root.style.colorScheme = isDark ? "dark" : "light";
root.classList.toggle("dark", meta.dark);
root.classList.toggle("theme-tokyo-night", theme === "tokyo-night");
root.style.colorScheme = meta.dark ? "dark" : "light";
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
if (themeColorMeta instanceof HTMLMetaElement) {
themeColorMeta.setAttribute("content", isDark ? DARK_THEME_COLOR : LIGHT_THEME_COLOR);
themeColorMeta.setAttribute("content", meta.bg);
}
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => resolveThemeFromDocument());
const [theme, setThemeState] = useState<Theme>(() => readStoredTheme());
const setTheme = useCallback((nextTheme: Theme) => {
setThemeState(nextTheme);
}, []);
const toggleTheme = useCallback(() => {
setThemeState((current) => (current === "dark" ? "light" : "dark"));
setThemeState((current) => {
const idx = VALID_THEMES.indexOf(current);
return VALID_THEMES[(idx + 1) % VALID_THEMES.length];
});
}, []);
useEffect(() => {
@ -54,7 +74,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
try {
localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch {
// Ignore local storage write failures in restricted environments.
// Ignore localStorage write failures in restricted environments.
}
}, [theme]);

View file

@ -45,73 +45,109 @@
:root {
color-scheme: light;
--radius: 0;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--background: #eff1f5;
--foreground: #4c4f69;
--card: #e6e9ef;
--card-foreground: #4c4f69;
--popover: #e6e9ef;
--popover-foreground: #4c4f69;
--primary: #1e66f5;
--primary-foreground: #eff1f5;
--secondary: #ccd0da;
--secondary-foreground: #4c4f69;
--muted: #ccd0da;
--muted-foreground: #9ca0b0;
--accent: #bcc0cc;
--accent-foreground: #4c4f69;
--destructive: #d20f39;
--destructive-foreground: #eff1f5;
--border: #ccd0da;
--input: #ccd0da;
--ring: #1e66f5;
--chart-1: #1e66f5;
--chart-2: #40a02b;
--chart-3: #8839ef;
--chart-4: #df8e1d;
--chart-5: #d20f39;
--sidebar: #e6e9ef;
--sidebar-foreground: #4c4f69;
--sidebar-primary: #1e66f5;
--sidebar-primary-foreground: #eff1f5;
--sidebar-accent: #ccd0da;
--sidebar-accent-foreground: #4c4f69;
--sidebar-border: #ccd0da;
--sidebar-ring: #1e66f5;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.637 0.237 25.331);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.145 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
color-scheme: dark;
--background: #1e1e2e;
--foreground: #cdd6f4;
--card: #181825;
--card-foreground: #cdd6f4;
--popover: #181825;
--popover-foreground: #cdd6f4;
--primary: #89b4fa;
--primary-foreground: #1e1e2e;
--secondary: #313244;
--secondary-foreground: #cdd6f4;
--muted: #313244;
--muted-foreground: #6c7086;
--accent: #45475a;
--accent-foreground: #cdd6f4;
--destructive: #f38ba8;
--destructive-foreground: #1e1e2e;
--border: #313244;
--input: #313244;
--ring: #89b4fa;
--chart-1: #89b4fa;
--chart-2: #a6e3a1;
--chart-3: #cba6f7;
--chart-4: #f9e2af;
--chart-5: #f38ba8;
--sidebar: #181825;
--sidebar-foreground: #cdd6f4;
--sidebar-primary: #89b4fa;
--sidebar-primary-foreground: #1e1e2e;
--sidebar-accent: #313244;
--sidebar-accent-foreground: #cdd6f4;
--sidebar-border: #313244;
--sidebar-ring: #89b4fa;
}
.theme-tokyo-night.dark {
--background: #1a1b26;
--foreground: #c0caf5;
--card: #16161e;
--card-foreground: #c0caf5;
--popover: #16161e;
--popover-foreground: #c0caf5;
--primary: #7aa2f7;
--primary-foreground: #1a1b26;
--secondary: #292e42;
--secondary-foreground: #c0caf5;
--muted: #292e42;
--muted-foreground: #565f89;
--accent: #3b4261;
--accent-foreground: #c0caf5;
--destructive: #f7768e;
--destructive-foreground: #1a1b26;
--border: #292e42;
--input: #292e42;
--ring: #7aa2f7;
--chart-1: #7aa2f7;
--chart-2: #9ece6a;
--chart-3: #bb9af7;
--chart-4: #e0af68;
--chart-5: #f7768e;
--sidebar: #16161e;
--sidebar-foreground: #c0caf5;
--sidebar-primary: #7aa2f7;
--sidebar-primary-foreground: #1a1b26;
--sidebar-accent: #292e42;
--sidebar-accent-foreground: #c0caf5;
--sidebar-border: #292e42;
--sidebar-ring: #7aa2f7;
}
@layer base {
@ -160,10 +196,6 @@
}
/* Dark mode scrollbars */
.dark {
color-scheme: dark;
}
.dark *::-webkit-scrollbar {
width: 8px;
height: 8px;

View file

@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { VOCAB } from "@paperclipai/branding";
import { useQuery } from "@tanstack/react-query";
import { activityApi } from "../api/activity";
import { agentsApi } from "../api/agents";
@ -82,7 +83,7 @@ export function Activity() {
}, [issues]);
if (!selectedCompanyId) {
return <EmptyState icon={History} message="Select a company to view activity." />;
return <EmptyState icon={History} message={`Select a ${VOCAB.company.toLowerCase()} to view activity.`} />;
}
if (isLoading) {

View file

@ -925,7 +925,7 @@ export function AgentDetail() {
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
{isPendingApproval && (
<p className="text-sm text-amber-500">
This agent is pending board approval and cannot be invoked yet.
This agent is pending owner approval and cannot be invoked yet.
</p>
)}
@ -1479,11 +1479,11 @@ function ConfigurationTab({
const taskAssignLocked = agent.role === "ceo" || canCreateAgents;
const taskAssignHint =
taskAssignSource === "ceo_role"
? "Enabled automatically for CEO agents."
? `Enabled automatically for ${VOCAB.ceo} agents.`
: taskAssignSource === "agent_creator"
? "Enabled automatically while this agent can create new agents."
: taskAssignSource === "explicit_grant"
? "Enabled via explicit company permission grant."
? `Enabled via explicit ${VOCAB.company.toLowerCase()} permission grant.`
: "Disabled unless explicitly granted.";
return (
@ -1727,7 +1727,7 @@ function PromptsTab({
const uploadMarkdownImage = useMutation({
mutationFn: async ({ file, namespace }: { file: File; namespace: string }) => {
if (!selectedCompanyId) throw new Error("Select a company to upload images");
if (!selectedCompanyId) throw new Error(`Select a ${VOCAB.company.toLowerCase()} to upload images`);
return assetsApi.uploadImage(selectedCompanyId, file, namespace);
},
});
@ -1927,7 +1927,7 @@ function PromptsTab({
<HelpCircle className="h-3 w-3 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" sideOffset={4}>
Managed: Paperclip stores and serves the instructions bundle. External: you provide a path on disk where the instructions live.
{`Managed: ${VOCAB.appName} stores and serves the instructions bundle. External: you provide a path on disk where the instructions live.`}
</TooltipContent>
</Tooltip>
</span>
@ -1982,7 +1982,7 @@ function PromptsTab({
<HelpCircle className="h-3 w-3 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" sideOffset={4}>
The absolute directory on disk where the instructions bundle lives. In managed mode this is set by Paperclip automatically.
{`The absolute directory on disk where the instructions bundle lives. In managed mode this is set by ${VOCAB.appName} automatically.`}
</TooltipContent>
</Tooltip>
</span>
@ -2512,9 +2512,9 @@ function AgentSkillsTab({
const unsupportedSkillMessage = useMemo(() => {
if (skillSnapshot?.mode !== "unsupported") return null;
if (agent.adapterType === "openclaw_gateway") {
return "Paperclip cannot manage OpenClaw skills here. Visit your OpenClaw instance to manage this agent's skills.";
return `${VOCAB.appName} cannot manage OpenClaw skills here. Visit your OpenClaw instance to manage this agent's skills.`;
}
return "Paperclip cannot manage skills for this adapter yet. Manage them in the adapter directly.";
return `${VOCAB.appName} cannot manage skills for this adapter yet. Manage them in the adapter directly.`;
}, [agent.adapterType, skillSnapshot?.mode]);
const hasUnsavedChanges = !arraysEqual(skillDraft, lastSavedSkills);
const saveStatusLabel = syncSkills.isPending
@ -2672,7 +2672,7 @@ function AgentSkillsTab({
<section className="border-y border-border">
<div className="border-b border-border bg-muted/40 px-3 py-2">
<span className="text-xs font-medium text-muted-foreground">
Required by Paperclip
{`Required by ${VOCAB.appName}`}
</span>
</div>
{requiredSkillRows.map(renderSkillRow)}
@ -2683,7 +2683,7 @@ function AgentSkillsTab({
<section className="border-y border-border">
<div className="border-b border-border bg-muted/40 px-3 py-2">
<span className="text-xs font-medium text-muted-foreground">
User-installed skills, not managed by Paperclip
{`User-installed skills, not managed by ${VOCAB.appName}`}
</span>
</div>
{unmanagedSkillRows.map(renderSkillRow)}
@ -2695,7 +2695,7 @@ function AgentSkillsTab({
{desiredOnlyMissingSkills.length > 0 && (
<div className="rounded-xl border border-amber-300/60 bg-amber-50/60 px-4 py-3 text-sm text-amber-800 dark:border-amber-500/30 dark:bg-amber-950/20 dark:text-amber-200">
<div className="font-medium">Requested skills missing from the company library</div>
<div className="font-medium">{`Requested skills missing from the ${VOCAB.company.toLowerCase()} library`}</div>
<div className="mt-1 text-xs">
{desiredOnlyMissingSkills.join(", ")}
</div>
@ -3969,7 +3969,7 @@ function KeysTab({ agentId, companyId }: { agentId: string; companyId?: string }
Create API Key
</h3>
<p className="text-xs text-muted-foreground">
API keys allow this agent to authenticate calls to the Paperclip server.
{`API keys allow this agent to authenticate calls to the ${VOCAB.appName} server.`}
</p>
<div className="flex items-center gap-2">
<Input

View file

@ -1,4 +1,5 @@
import { useState, useEffect, useMemo } from "react";
import { VOCAB } from "@paperclipai/branding";
import { Link, useNavigate, useLocation } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { agentsApi, type OrgNode } from "../api/agents";
@ -122,7 +123,7 @@ export function Agents() {
}, [setBreadcrumbs]);
if (!selectedCompanyId) {
return <EmptyState icon={Bot} message="Select a company to view agents." />;
return <EmptyState icon={Bot} message={`Select a ${VOCAB.company.toLowerCase()} to view agents.`} />;
}
if (isLoading) {

View file

@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { VOCAB } from "@paperclipai/branding";
import { useNavigate, useLocation } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { approvalsApi } from "../api/approvals";
@ -75,7 +76,7 @@ export function Approvals() {
).length;
if (!selectedCompanyId) {
return <p className="text-sm text-muted-foreground">Select a company first.</p>;
return <p className="text-sm text-muted-foreground">{`Select a ${VOCAB.company.toLowerCase()} first.`}</p>;
}
if (isLoading) {

View file

@ -143,7 +143,7 @@ export function CliAuthPage() {
</div>
{challenge.requestedCompanyName && (
<div>
<div className="text-muted-foreground">Requested company</div>
<div className="text-muted-foreground">{`Requested ${VOCAB.company.toLowerCase()}`}</div>
<div className="text-foreground">{challenge.requestedCompanyName}</div>
</div>
)}

View file

@ -70,7 +70,7 @@ export function Companies() {
});
useEffect(() => {
setBreadcrumbs([{ label: "Companies" }]);
setBreadcrumbs([{ label: VOCAB.companies }]);
}, [setBreadcrumbs]);
function startEdit(companyId: string, currentName: string) {
@ -98,7 +98,7 @@ export function Companies() {
</div>
<div className="h-6">
{loading && <p className="text-sm text-muted-foreground">Loading companies...</p>}
{loading && <p className="text-sm text-muted-foreground">{`Loading ${VOCAB.companies.toLowerCase()}...`}</p>}
{error && <p className="text-sm text-destructive">{error.message}</p>}
</div>
@ -267,7 +267,7 @@ export function Companies() {
onClick={(e) => e.stopPropagation()}
>
<p className="text-sm text-destructive font-medium">
Delete this company and all its data? This cannot be undone.
{`Delete this ${VOCAB.company.toLowerCase()} and all its data? This cannot be undone.`}
</p>
<div className="flex items-center gap-2 ml-4 shrink-0">
<Button

View file

@ -912,7 +912,7 @@ export function CompanyExport() {
}
if (!selectedCompanyId) {
return <EmptyState icon={Package} message="Select a company to export." />;
return <EmptyState icon={Package} message={`Select a ${VOCAB.company.toLowerCase()} to export.`} />;
}
if (exportPreviewMutation.isPending && !exportData) {

View file

@ -704,7 +704,7 @@ export function CompanyImport() {
}, [companyAgents]);
const localZipHelpText =
"Upload a .zip exported directly from Paperclip. Re-zipped archives created by Finder, Explorer, or other zip tools may not import correctly.";
`Upload a .zip exported directly from ${VOCAB.appName}. Re-zipped archives created by Finder, Explorer, or other zip tools may not import correctly.`;
useEffect(() => {
setBreadcrumbs([
@ -1086,7 +1086,7 @@ export function CompanyImport() {
const selectedAction = selectedFile ? (actionMap.get(selectedFile) ?? null) : null;
if (!selectedCompanyId) {
return <EmptyState icon={Download} message="Select a company to import into." />;
return <EmptyState icon={Download} message={`Select a ${VOCAB.company.toLowerCase()} to import into.`} />;
}
return (
@ -1096,7 +1096,7 @@ export function CompanyImport() {
<div>
<h2 className="text-base font-semibold">Import source</h2>
<p className="text-xs text-muted-foreground mt-1">
Choose a GitHub repo or upload a local Paperclip zip package.
{`Choose a GitHub repo or upload a local ${VOCAB.appName} zip package.`}
</p>
</div>
@ -1178,7 +1178,7 @@ export function CompanyImport() {
</Field>
)}
<Field label="Target" hint="Import into this company or create a new one.">
<Field label="Target" hint={`Import into this ${VOCAB.company.toLowerCase()} or create a new one.`}>
<select
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
value={targetMode}
@ -1187,16 +1187,16 @@ export function CompanyImport() {
setImportPreview(null);
}}
>
<option value="new">Create new company</option>
<option value="new">{`Create new ${VOCAB.company.toLowerCase()}`}</option>
<option value="existing">
Existing company: {selectedCompany?.name}
{`Existing ${VOCAB.company.toLowerCase()}: ${selectedCompany?.name}`}
</option>
</select>
</Field>
{targetMode === "new" && (
<Field
label="New company name"
label={`New ${VOCAB.company.toLowerCase()} name`}
hint="Optional override. Leave blank to use the package name."
>
<input
@ -1211,7 +1211,7 @@ export function CompanyImport() {
<Field
label="Collision strategy"
hint="Board imports can rename, skip, or replace matching company content."
hint={`${VOCAB.board} imports can rename, skip, or replace matching ${VOCAB.company.toLowerCase()} content.`}
>
<select
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"

View file

@ -208,7 +208,7 @@ export function CompanySettings() {
if (!selectedCompany) {
return (
<div className="text-sm text-muted-foreground">
No company selected. Select a company from the switcher above.
{`No ${VOCAB.company.toLowerCase()} selected. Select a ${VOCAB.company.toLowerCase()} from the switcher above.`}
</div>
);
}

View file

@ -989,7 +989,7 @@ export function CompanySkills() {
});
if (!selectedCompanyId) {
return <EmptyState icon={Boxes} message="Select a company to manage skills." />;
return <EmptyState icon={Boxes} message={`Select a ${VOCAB.company.toLowerCase()} to manage skills.`} />;
}
function handleAddSkillSource() {

View file

@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState, type ComponentType } from "react";
import { VOCAB } from "@paperclipai/branding";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
BudgetPolicySummary,
@ -35,6 +36,12 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const NO_COMPANY = "__none__";
const SCOPE_LABELS: Record<string, string> = {
company: VOCAB.company,
agent: "Agent",
project: "Project",
};
function currentWeekRange(): { from: string; to: string } {
const now = new Date();
const day = now.getDay();
@ -529,7 +536,7 @@ export function Costs() {
}), [budgetPolicies]);
if (!selectedCompanyId) {
return <EmptyState icon={DollarSign} message="Select a company to view costs." />;
return <EmptyState icon={DollarSign} message={`Select a ${VOCAB.company.toLowerCase()} to view costs.`} />;
}
const showCustomPrompt = preset === "custom" && !customReady;
@ -855,7 +862,7 @@ export function Costs() {
<MetricTile
label="Pending approvals"
value={String(budgetData?.pendingApprovalCount ?? 0)}
subtitle="Budget override approvals awaiting board action"
subtitle="Budget override approvals awaiting owner action"
icon={ArrowUpRight}
/>
<MetricTile
@ -907,10 +914,10 @@ export function Costs() {
return (
<section key={scopeType} className="space-y-3">
<div>
<h2 className="text-lg font-semibold capitalize">{scopeType} budgets</h2>
<h2 className="text-lg font-semibold">{SCOPE_LABELS[scopeType] ?? scopeType} budgets</h2>
<p className="text-sm text-muted-foreground">
{scopeType === "company"
? "Company-wide monthly policy."
? `${VOCAB.company}-wide monthly policy.`
: scopeType === "agent"
? "Recurring monthly spend policies for individual agents."
: "Lifetime spend policies for execution-bound projects."}
@ -939,7 +946,7 @@ export function Costs() {
{budgetPolicies.length === 0 ? (
<Card>
<CardContent className="px-5 py-8 text-sm text-muted-foreground">
No budget policies yet. Set agent and project budgets from their detail pages, or use the existing company monthly budget control.
{`No budget policies yet. Set agent and project budgets from their detail pages, or use the existing ${VOCAB.company.toLowerCase()} monthly budget control.`}
</CardContent>
</Card>
) : null}

View file

@ -277,8 +277,8 @@ export function Dashboard() {
description={
<span>
{data.budgets.pendingApprovals > 0
? `${data.budgets.pendingApprovals} budget overrides awaiting board review`
: "Awaiting board review"}
? `${data.budgets.pendingApprovals} budget overrides awaiting owner review`
: "Awaiting owner review"}
</span>
}
/>

View file

@ -1,4 +1,5 @@
import { useState } from "react";
import { VOCAB } from "@paperclipai/branding";
import {
BookOpen,
Bot,
@ -194,7 +195,7 @@ export function DesignGuide() {
<div>
<h2 className="text-xl font-bold">Design Guide</h2>
<p className="text-sm text-muted-foreground mt-1">
Every component, style, and pattern used across Paperclip.
{`Every component, style, and pattern used across ${VOCAB.appName}.`}
</p>
</div>
@ -736,7 +737,7 @@ export function DesignGuide() {
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="#">Paperclip App</BreadcrumbLink>
<BreadcrumbLink href="#">{`${VOCAB.appName} App`}</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
@ -943,7 +944,7 @@ export function DesignGuide() {
<SubSection title="Initials derivation">
<div className="flex flex-col gap-2">
<Identity name="CEO Agent" size="sm" />
<Identity name={`${VOCAB.ceo} Agent`} size="sm" />
<Identity name="Alpha" size="sm" />
<Identity name="Quality Assurance Lead" size="sm" />
</div>

View file

@ -1,4 +1,5 @@
import { useEffect } from "react";
import { VOCAB } from "@paperclipai/branding";
import { useQuery } from "@tanstack/react-query";
import { goalsApi } from "../api/goals";
import { useCompany } from "../context/CompanyContext";
@ -27,7 +28,7 @@ export function Goals() {
});
if (!selectedCompanyId) {
return <EmptyState icon={Target} message="Select a company to view goals." />;
return <EmptyState icon={Target} message={`Select a ${VOCAB.company.toLowerCase()} to view goals.`} />;
}
if (isLoading) {

View file

@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { VOCAB } from "@paperclipai/branding";
import { Link, useLocation, useNavigate } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { approvalsApi } from "../api/approvals";
@ -909,7 +910,7 @@ export function Inbox() {
};
if (!selectedCompanyId) {
return <EmptyState icon={InboxIcon} message="Select a company to view inbox." />;
return <EmptyState icon={InboxIcon} message={`Select a ${VOCAB.company.toLowerCase()} to view inbox.`} />;
}
const hasRunFailures = failedRuns.length > 0;

View file

@ -3,13 +3,17 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { SlidersHorizontal } from "lucide-react";
import { instanceSettingsApi } from "@/api/instanceSettings";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useTheme, THEME_META, type Theme } from "../context/ThemeContext";
import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils";
const ORDERED_THEMES: Theme[] = ["catppuccin-mocha", "tokyo-night", "catppuccin-latte"];
export function InstanceGeneralSettings() {
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const [actionError, setActionError] = useState<string | null>(null);
const { theme, setTheme } = useTheme();
useEffect(() => {
setBreadcrumbs([
@ -69,6 +73,37 @@ export function InstanceGeneralSettings() {
</div>
)}
<section className="rounded-xl border border-border bg-card p-5">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Theme</h2>
<p className="text-sm text-muted-foreground">Choose the visual theme for Nexus.</p>
</div>
<div className="mt-4 flex flex-wrap gap-3">
{ORDERED_THEMES.map((id) => {
const meta = THEME_META[id];
return (
<button
key={id}
type="button"
onClick={() => setTheme(id)}
className={cn(
"flex items-center gap-2.5 rounded-lg border px-3 py-2 text-sm transition-colors",
theme === id
? "border-primary bg-primary/10 text-primary"
: "border-border bg-card text-foreground hover:border-ring",
)}
>
<span
className="h-4 w-4 rounded-full border border-border/50 shrink-0"
style={{ backgroundColor: meta.primary }}
/>
{meta.label}
</button>
);
})}
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">

View file

@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { VOCAB } from "@paperclipai/branding";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Clock3, ExternalLink, Settings } from "lucide-react";
import type { InstanceSchedulerHeartbeatAgent } from "@paperclipai/shared";
@ -171,14 +172,14 @@ export function InstanceSettings() {
<h1 className="text-lg font-semibold">Scheduler Heartbeats</h1>
</div>
<p className="text-sm text-muted-foreground">
Agents with a timer heartbeat enabled across all of your companies.
{`Agents with a timer heartbeat enabled across all of your ${VOCAB.companies.toLowerCase()}.`}
</p>
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span><span className="font-semibold text-foreground">{activeCount}</span> active</span>
<span><span className="font-semibold text-foreground">{disabledCount}</span> disabled</span>
<span><span className="font-semibold text-foreground">{grouped.length}</span> {grouped.length === 1 ? "company" : "companies"}</span>
<span><span className="font-semibold text-foreground">{grouped.length}</span> {grouped.length === 1 ? VOCAB.company.toLowerCase() : VOCAB.companies.toLowerCase()}</span>
{anyEnabled && (
<Button
variant="destructive"

View file

@ -1,4 +1,5 @@
import { useEffect, useMemo, useCallback } from "react";
import { VOCAB } from "@paperclipai/branding";
import { useLocation, useSearchParams } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { issuesApi } from "../api/issues";
@ -93,7 +94,7 @@ export function Issues() {
});
if (!selectedCompanyId) {
return <EmptyState icon={CircleDot} message="Select a company to view issues." />;
return <EmptyState icon={CircleDot} message={`Select a ${VOCAB.company.toLowerCase()} to view issues.`} />;
}
return (

View file

@ -1,4 +1,5 @@
import { useEffect } from "react";
import { VOCAB } from "@paperclipai/branding";
import { useQuery } from "@tanstack/react-query";
import { issuesApi } from "../api/issues";
import { useCompany } from "../context/CompanyContext";
@ -27,7 +28,7 @@ export function MyIssues() {
});
if (!selectedCompanyId) {
return <EmptyState icon={ListTodo} message="Select a company to view your issues." />;
return <EmptyState icon={ListTodo} message={`Select a ${VOCAB.company.toLowerCase()} to view your issues.`} />;
}
if (isLoading) {

View file

@ -331,7 +331,7 @@ export function NewAgent() {
{/* Footer */}
<div className="border-t border-border px-4 py-3">
{isFirstAgent && (
<p className="text-xs text-muted-foreground mb-2">This will be the CEO</p>
<p className="text-xs text-muted-foreground mb-2">{`This will be the ${VOCAB.ceo}`}</p>
)}
{formError && (
<p className="text-xs text-destructive mb-2">{formError}</p>

View file

@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { VOCAB } from "@paperclipai/branding";
import { Link } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { agentsApi, type OrgNode } from "../api/agents";
@ -104,7 +105,7 @@ export function Org() {
});
if (!selectedCompanyId) {
return <EmptyState icon={GitBranch} message="Select a company to view org chart." />;
return <EmptyState icon={GitBranch} message={`Select a ${VOCAB.company.toLowerCase()} to view org chart.`} />;
}
if (isLoading) {

View file

@ -1,4 +1,5 @@
import { useEffect, useRef, useState, useMemo, useCallback } from "react";
import { VOCAB } from "@paperclipai/branding";
import { Link, useNavigate } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { agentsApi, type OrgNode } from "../api/agents";
@ -256,7 +257,7 @@ export function OrgChart() {
}, [zoom, pan]);
if (!selectedCompanyId) {
return <EmptyState icon={Network} message="Select a company to view the org chart." />;
return <EmptyState icon={Network} message={`Select a ${VOCAB.company.toLowerCase()} to view the org chart.`} />;
}
if (isLoading) {

View file

@ -1,4 +1,5 @@
import { useEffect, useMemo } from "react";
import { VOCAB } from "@paperclipai/branding";
import { Link, Navigate, useParams } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { useCompany } from "@/context/CompanyContext";
@ -104,7 +105,7 @@ export function PluginPage() {
}
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">Select a company to view this page.</p>
<p className="text-sm text-muted-foreground">{`Select a ${VOCAB.company.toLowerCase()} to view this page.`}</p>
</div>
);
}

View file

@ -1,4 +1,5 @@
import { useEffect, useMemo } from "react";
import { VOCAB } from "@paperclipai/branding";
import { useQuery } from "@tanstack/react-query";
import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
@ -33,7 +34,7 @@ export function Projects() {
);
if (!selectedCompanyId) {
return <EmptyState icon={Hexagon} message="Select a company to view projects." />;
return <EmptyState icon={Hexagon} message={`Select a ${VOCAB.company.toLowerCase()} to view projects.`} />;
}
if (isLoading) {

View file

@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { VOCAB } from "@paperclipai/branding";
import { Link, useLocation, useNavigate, useParams } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
@ -402,7 +403,7 @@ export function RoutineDetail() {
onError: (error) => {
pushToast({
title: "Failed to save routine",
body: error instanceof Error ? error.message : "Paperclip could not save the routine.",
body: error instanceof Error ? error.message : `${VOCAB.appName} could not save the routine.`,
tone: "error",
});
},
@ -423,7 +424,7 @@ export function RoutineDetail() {
onError: (error) => {
pushToast({
title: "Routine run failed",
body: error instanceof Error ? error.message : "Paperclip could not start the routine run.",
body: error instanceof Error ? error.message : `${VOCAB.appName} could not start the routine run.`,
tone: "error",
});
},
@ -445,7 +446,7 @@ export function RoutineDetail() {
onError: (error) => {
pushToast({
title: "Failed to update routine",
body: error instanceof Error ? error.message : "Paperclip could not update the routine.",
body: error instanceof Error ? error.message : `${VOCAB.appName} could not update the routine.`,
tone: "error",
});
},
@ -486,7 +487,7 @@ export function RoutineDetail() {
onError: (error) => {
pushToast({
title: "Failed to add trigger",
body: error instanceof Error ? error.message : "Paperclip could not create the trigger.",
body: error instanceof Error ? error.message : `${VOCAB.appName} could not create the trigger.`,
tone: "error",
});
},
@ -504,7 +505,7 @@ export function RoutineDetail() {
onError: (error) => {
pushToast({
title: "Failed to update trigger",
body: error instanceof Error ? error.message : "Paperclip could not update the trigger.",
body: error instanceof Error ? error.message : `${VOCAB.appName} could not update the trigger.`,
tone: "error",
});
},
@ -522,7 +523,7 @@ export function RoutineDetail() {
onError: (error) => {
pushToast({
title: "Failed to delete trigger",
body: error instanceof Error ? error.message : "Paperclip could not delete the trigger.",
body: error instanceof Error ? error.message : `${VOCAB.appName} could not delete the trigger.`,
tone: "error",
});
},
@ -544,7 +545,7 @@ export function RoutineDetail() {
onError: (error) => {
pushToast({
title: "Failed to rotate webhook secret",
body: error instanceof Error ? error.message : "Paperclip could not rotate the webhook secret.",
body: error instanceof Error ? error.message : `${VOCAB.appName} could not rotate the webhook secret.`,
tone: "error",
});
},
@ -584,7 +585,7 @@ export function RoutineDetail() {
const currentProject = editDraft.projectId ? projectById.get(editDraft.projectId) ?? null : null;
if (!selectedCompanyId) {
return <EmptyState icon={Repeat} message="Select a company to view routines." />;
return <EmptyState icon={Repeat} message={`Select a ${VOCAB.company.toLowerCase()} to view routines.`} />;
}
if (isLoading) {
@ -673,7 +674,7 @@ export function RoutineDetail() {
<div className="rounded-lg border border-blue-500/30 bg-blue-500/5 p-4 space-y-3 text-sm">
<div>
<p className="font-medium">{secretMessage.title}</p>
<p className="text-xs text-muted-foreground">Save this now. Paperclip will not show the secret value again.</p>
<p className="text-xs text-muted-foreground">{`Save this now. ${VOCAB.appName} will not show the secret value again.`}</p>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">

View file

@ -219,7 +219,7 @@ export function Routines() {
const currentProject = draft.projectId ? projectById.get(draft.projectId) ?? null : null;
if (!selectedCompanyId) {
return <EmptyState icon={Repeat} message="Select a company to view routines." />;
return <EmptyState icon={Repeat} message={`Select a ${VOCAB.company.toLowerCase()} to view routines.`} />;
}
if (isLoading) {

View file

@ -6,13 +6,20 @@ import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
lexical: path.resolve(__dirname, "./node_modules/lexical/Lexical.mjs"),
// [nexus] Replace upstream OnboardingWizard with Nexus single-step version
[path.resolve(__dirname, "src/components/OnboardingWizard")]:
path.resolve(__dirname, "./src/components/NexusOnboardingWizard"),
},
alias: [
{ find: "@", replacement: path.resolve(__dirname, "./src") },
{
find: "lexical",
replacement: path.resolve(__dirname, "./node_modules/lexical/Lexical.mjs"),
},
// [nexus] Replace upstream OnboardingWizard with Nexus single-step version.
// RegExp required: string keys match the RAW import specifier (not resolved path).
// App.tsx imports './components/OnboardingWizard' — must match exactly.
{
find: /^\.\/components\/OnboardingWizard$/,
replacement: path.resolve(__dirname, "./src/components/NexusOnboardingWizard"),
},
],
},
server: {
port: 5173,