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, readAgentJwtSecretFromEnvFile,
resolveAgentJwtEnvFile, resolveAgentJwtEnvFile,
} from "../config/env.js"; } from "../config/env.js";
import { VOCAB } from "@paperclipai/branding";
import type { CheckResult } from "./index.js"; import type { CheckResult } from "./index.js";
export function agentJwtSecretCheck(configPath?: string): CheckResult { export function agentJwtSecretCheck(configPath?: string): CheckResult {
@ -23,7 +24,7 @@ export function agentJwtSecretCheck(configPath?: string): CheckResult {
name: "Agent JWT secret", name: "Agent JWT secret",
status: "warn", status: "warn",
message: `PAPERCLIP_AGENT_JWT_SECRET is present in ${envPath} but not loaded into environment`, 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 type { PaperclipConfig } from "../config/schema.js";
import { VOCAB } from "@paperclipai/branding";
import type { CheckResult } from "./index.js"; import type { CheckResult } from "./index.js";
function isLoopbackHost(host: string) { function isLoopbackHost(host: string) {
@ -37,7 +38,7 @@ export function deploymentAuthCheck(config: PaperclipConfig): CheckResult {
status: "fail", status: "fail",
message: "authenticated mode requires BETTER_AUTH_SECRET (or PAPERCLIP_AGENT_JWT_SECRET)", message: "authenticated mode requires BETTER_AUTH_SECRET (or PAPERCLIP_AGENT_JWT_SECRET)",
canRepair: false, 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 * as p from "@clack/prompts";
import { VOCAB } from "@paperclipai/branding";
import pc from "picocolors"; import pc from "picocolors";
import { normalizeHostnameInput } from "../config/hostnames.js"; import { normalizeHostnameInput } from "../config/hostnames.js";
import { readConfig, resolveConfigPath, writeConfig } from "../config/store.js"; import { readConfig, resolveConfigPath, writeConfig } from "../config/store.js";
@ -27,7 +28,7 @@ export async function addAllowedHostname(host: string, opts: { config?: string }
} else { } else {
p.log.success(`Added allowed hostname: ${pc.cyan(normalized)}`); p.log.success(`Added allowed hostname: ${pc.cyan(normalized)}`);
p.log.message( 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 { Command } from "commander";
import { VOCAB } from "@paperclipai/branding";
import type { ActivityEvent } from "@paperclipai/shared"; import type { ActivityEvent } from "@paperclipai/shared";
import { import {
addCommonClientOptions, addCommonClientOptions,
@ -22,8 +23,8 @@ export function registerActivityCommands(program: Command): void {
addCommonClientOptions( addCommonClientOptions(
activity activity
.command("list") .command("list")
.description("List company activity log entries") .description(`List ${VOCAB.company.toLowerCase()} activity log entries`)
.requiredOption("-C, --company-id <id>", "Company ID") .requiredOption("-C, --company-id <id>", `${VOCAB.company} ID`)
.option("--agent-id <id>", "Filter by agent ID") .option("--agent-id <id>", "Filter by agent ID")
.option("--entity-type <type>", "Filter by entity type") .option("--entity-type <type>", "Filter by entity type")
.option("--entity-id <id>", "Filter by entity ID") .option("--entity-id <id>", "Filter by entity ID")

View file

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

View file

@ -1,4 +1,5 @@
import { Command } from "commander"; import { Command } from "commander";
import { VOCAB } from "@paperclipai/branding";
import { import {
createApprovalSchema, createApprovalSchema,
requestApprovalRevisionSchema, requestApprovalRevisionSchema,
@ -48,8 +49,8 @@ export function registerApprovalCommands(program: Command): void {
addCommonClientOptions( addCommonClientOptions(
approval approval
.command("list") .command("list")
.description("List approvals for a company") .description(`List approvals for a ${VOCAB.company.toLowerCase()}`)
.requiredOption("-C, --company-id <id>", "Company ID") .requiredOption("-C, --company-id <id>", `${VOCAB.company} ID`)
.option("--status <status>", "Status filter") .option("--status <status>", "Status filter")
.action(async (opts: ApprovalListOptions) => { .action(async (opts: ApprovalListOptions) => {
try { try {
@ -110,7 +111,7 @@ export function registerApprovalCommands(program: Command): void {
approval approval
.command("create") .command("create")
.description("Create an approval request") .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("--type <type>", "Approval type (hire_agent|approve_ceo_strategy)")
.requiredOption("--payload <json>", "Approval payload as JSON object") .requiredOption("--payload <json>", "Approval payload as JSON object")
.option("--requested-by-agent-id <id>", "Requesting agent ID") .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: "projects", label: "Projects", hint: "projects and workspace metadata" },
{ value: "issues", label: "Tasks", hint: "tasks and recurring routines" }, { value: "issues", label: "Tasks", hint: "tasks and recurring routines" },
{ value: "agents", label: "Agents", hint: "agent records and org structure" }, { 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; const IMPORT_PREVIEW_SAMPLE_LIMIT = 6;
@ -1046,7 +1046,7 @@ export function registerCompanyCommands(program: Command): void {
addCommonClientOptions( addCommonClientOptions(
company company
.command("list") .command("list")
.description("List companies") .description(`List ${VOCAB.companies.toLowerCase()}`) // [nexus]
.action(async (opts: CompanyCommandOptions) => { .action(async (opts: CompanyCommandOptions) => {
try { try {
const ctx = resolveCommandContext(opts); const ctx = resolveCommandContext(opts);
@ -1081,8 +1081,8 @@ export function registerCompanyCommands(program: Command): void {
addCommonClientOptions( addCommonClientOptions(
company company
.command("get") .command("get")
.description("Get one company") .description(`Get one ${VOCAB.company.toLowerCase()}`) // [nexus]
.argument("<companyId>", "Company ID") .argument("<companyId>", `${VOCAB.company} ID`) // [nexus]
.action(async (companyId: string, opts: CompanyCommandOptions) => { .action(async (companyId: string, opts: CompanyCommandOptions) => {
try { try {
const ctx = resolveCommandContext(opts); const ctx = resolveCommandContext(opts);
@ -1097,8 +1097,8 @@ export function registerCompanyCommands(program: Command): void {
addCommonClientOptions( addCommonClientOptions(
company company
.command("export") .command("export")
.description("Export a company into a portable markdown package") .description(`Export a ${VOCAB.company.toLowerCase()} into a portable markdown package`) // [nexus]
.argument("<companyId>", "Company ID") .argument("<companyId>", `${VOCAB.company} ID`) // [nexus]
.requiredOption("--out <path>", "Output directory") .requiredOption("--out <path>", "Output directory")
.option("--include <values>", "Comma-separated include set: company,agents,projects,issues,tasks,skills", "company,agents") .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") .option("--skills <values>", "Comma-separated skill slugs/keys to export")
@ -1373,8 +1373,8 @@ export function registerCompanyCommands(program: Command): void {
addCommonClientOptions( addCommonClientOptions(
company company
.command("delete") .command("delete")
.description("Delete a company by ID or shortname/prefix (destructive)") .description(`Delete a ${VOCAB.company.toLowerCase()} by ID or shortname/prefix (destructive)`) // [nexus]
.argument("<selector>", "Company ID or issue prefix (for example PAP)") .argument("<selector>", `${VOCAB.company} ID or issue prefix (for example PAP)`) // [nexus]
.option( .option(
"--by <mode>", "--by <mode>",
"Selector mode: auto | id | prefix", "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("--yes", "Required safety flag to confirm destructive action", false)
.option( .option(
"--confirm <value>", "--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) => { .action(async (selector: string, opts: CompanyDeleteOptions) => {
try { try {
@ -1425,7 +1425,7 @@ export function registerCompanyCommands(program: Command): void {
} catch (error) { } catch (error) {
if (error instanceof ApiRequestError && error.status === 403 && error.message.includes("Board access required")) { if (error instanceof ApiRequestError && error.status === 403 && error.message.includes("Board access required")) {
throw new Error( 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; throw error;

View file

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

View file

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

View file

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

View file

@ -77,6 +77,7 @@ import {
type PlannedIssueDocumentMerge, type PlannedIssueDocumentMerge,
type PlannedIssueInsert, type PlannedIssueInsert,
} from "./worktree-merge-history-lib.js"; } from "./worktree-merge-history-lib.js";
import { VOCAB } from "@paperclipai/branding";
type WorktreeInitOptions = { type WorktreeInitOptions = {
name?: string; name?: string;
@ -1538,7 +1539,7 @@ async function resolveMergeCompany(input: {
} }
if (shared.length === 0) { 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 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)") .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("--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("--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("--scope <items>", "Comma-separated scopes to import (issues, comments)", "issues,comments")
.option("--apply", "Apply the import after previewing the plan", false) .option("--apply", "Apply the import after previewing the plan", false)
.option("--dry", "Preview only and do not import anything", 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"; import { resolveServerDevWatchIgnorePaths } from "../src/dev-watch-ignore.ts";
const require = createRequire(import.meta.url); 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 serverRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const ignoreArgs = resolveServerDevWatchIgnorePaths(serverRoot).flatMap((ignorePath) => ["--exclude", ignorePath]); 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_ID = "local-board";
const LOCAL_BOARD_USER_EMAIL = "local@paperclip.local"; 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> { async function ensureLocalTrustedBoardPrincipal(db: any): Promise<void> {
const now = new Date(); const now = new Date();
@ -757,7 +757,7 @@ function isMainModule(metaUrl: string): boolean {
if (isMainModule(import.meta.url)) { if (isMainModule(import.meta.url)) {
void startServer().catch((err) => { void startServer().catch((err) => {
logger.error({ err }, "Paperclip server failed to start"); logger.error({ err }, "Nexus server failed to start"); // [nexus]
process.exit(1); process.exit(1);
}); });
} }

View file

@ -4,7 +4,7 @@
import type { CompanyPortabilityManifest } from "@paperclipai/shared"; import type { CompanyPortabilityManifest } from "@paperclipai/shared";
const ROLE_LABELS: Record<string, string> = { const ROLE_LABELS: Record<string, string> = {
ceo: "Project Manager", // [nexus] was: "CEO" ceo: "Project Manager", // [nexus] renamed from CEO
cto: "CTO", cto: "CTO",
cmo: "CMO", cmo: "CMO",
cfo: "CFO", cfo: "CFO",
@ -96,7 +96,7 @@ export function generateReadme(
// What's Inside table // What's Inside table
lines.push("## What's Inside"); lines.push("## What's Inside");
lines.push(""); 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(""); lines.push("");
const counts: Array<[string, number]> = []; const counts: Array<[string, number]> = [];
@ -157,15 +157,15 @@ export function generateReadme(
lines.push("## Getting Started"); lines.push("## Getting Started");
lines.push(""); lines.push("");
lines.push("```bash"); 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(""); lines.push("");
lines.push("See [Paperclip](https://paperclip.ing) for more information."); lines.push("See the Nexus documentation for more information."); // [nexus]
lines.push(""); lines.push("");
// Footer // Footer
lines.push("---"); 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(""); lines.push("");
return lines.join("\n"); return lines.join("\n");

View file

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

View file

@ -1,142 +1,65 @@
import { test, expect } from "@playwright/test"; 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: * Verifies:
* Step 1 Name your company * ONBD-10 Vite alias intercepts, NexusOnboardingWizard renders
* Step 2 Create your first agent (adapter selection + config) * ONBD-11 Single root-directory input only, no multi-step flow
* Step 3 Give it something to do (task creation) * ONBD-12 No corporate placeholder text visible
* 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).
*/ */
const SKIP_LLM = process.env.PAPERCLIP_E2E_SKIP_LLM !== "false"; test.describe("Nexus onboarding wizard", () => {
test("single-step flow: root dir input, no corporate strings, lands on dashboard", async ({ page }) => {
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 }) => {
await page.goto("/"); await page.goto("/");
const wizardHeading = page.locator("h3", { hasText: "Name your company" }); // ONBD-10 + ONBD-11: Nexus wizard renders with single-step heading
const newCompanyBtn = page.getByRole("button", { name: "New Company" }); const heading = page.locator("h1", { hasText: "Welcome to Nexus" });
await expect(heading).toBeVisible({ timeout: 15_000 });
await expect( // ONBD-11: Only a root directory input — no multi-step navigation
wizardHeading.or(newCompanyBtn) await expect(page.getByRole("button", { name: "Next" })).toHaveCount(0);
).toBeVisible({ timeout: 15_000 }); 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()) { // ONBD-12: No corporate placeholder text
await newCompanyBtn.click(); 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 page.getByRole("button", { name: "Get Started" }).click();
await companyNameInput.fill(COMPANY_NAME);
const nextButton = page.getByRole("button", { name: "Next" }); // Should navigate to dashboard
await nextButton.click(); await expect(page).toHaveURL(/\/dashboard/, { timeout: 30_000 });
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 });
// Verify workspace and agents created via API
const baseUrl = page.url().split("/").slice(0, 3).join("/"); const baseUrl = page.url().split("/").slice(0, 3).join("/");
const companiesRes = await page.request.get(`${baseUrl}/api/companies`); const companiesRes = await page.request.get(`${baseUrl}/api/companies`);
expect(companiesRes.ok()).toBe(true); expect(companiesRes.ok()).toBe(true);
const companies = await companiesRes.json(); const companies = await companiesRes.json();
const company = companies.find( expect(companies.length).toBeGreaterThan(0);
(c: { name: string }) => c.name === COMPANY_NAME
);
expect(company).toBeTruthy();
const companyId = companies[0].id;
const agentsRes = await page.request.get( const agentsRes = await page.request.get(
`${baseUrl}/api/companies/${company.id}/agents` `${baseUrl}/api/companies/${companyId}/agents`
); );
expect(agentsRes.ok()).toBe(true); expect(agentsRes.ok()).toBe(true);
const agents = await agentsRes.json(); 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( // PM agent (role: ceo, name: "Project Manager") and Engineer created
`${baseUrl}/api/agents/${ceoAgent.id}/instructions-bundle?companyId=${company.id}`
);
expect(instructionsBundleRes.ok()).toBe(true);
const instructionsBundle = await instructionsBundleRes.json();
expect( expect(
instructionsBundle.files.map((file: { path: string }) => file.path).sort() agents.some((a: { name: string }) => a.name === "Project Manager")
).toEqual(["AGENTS.md", "HEARTBEAT.md", "SOUL.md", "TOOLS.md"]); ).toBe(true);
expect(
const issuesRes = await page.request.get( agents.some((a: { name: string }) => a.name === "Engineer")
`${baseUrl}/api/companies/${company.id}/issues` ).toBe(true);
);
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] });
}
}); });
}); });

View file

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

View file

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

View file

@ -1,4 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { VOCAB } from "@paperclipai/branding";
import { Eye, EyeOff } from "lucide-react"; import { Eye, EyeOff } from "lucide-react";
import type { AdapterConfigFieldsProps } from "../types"; import type { AdapterConfigFieldsProps } from "../types";
import { import {
@ -134,7 +135,7 @@ export function OpenClawGatewayConfigFields({
{!isCreate && ( {!isCreate && (
<> <>
<Field label="Paperclip API URL override"> <Field label={`${VOCAB.appName} API URL override`}>
<DraftInput <DraftInput
value={ value={
eff( eff(
@ -226,7 +227,7 @@ export function OpenClawGatewayConfigFields({
<Field label="Device auth"> <Field label="Device auth">
<div className="text-xs text-muted-foreground leading-relaxed"> <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. remain stable across runs.
</div> </div>
</Field> </Field>

View file

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

View file

@ -1,4 +1,5 @@
import { useState, useEffect, useRef, useMemo, useCallback } from "react"; import { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { VOCAB } from "@paperclipai/branding";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared"; import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared";
import type { import type {
@ -186,7 +187,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const createSecret = useMutation({ const createSecret = useMutation({
mutationFn: (input: { name: string; value: string }) => { 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); return secretsApi.create(selectedCompanyId, input);
}, },
onSuccess: () => { onSuccess: () => {
@ -197,7 +198,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const uploadMarkdownImage = useMutation({ const uploadMarkdownImage = useMutation({
mutationFn: async ({ file, namespace }: { file: File; namespace: string }) => { 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); return assetsApi.uploadImage(selectedCompanyId, file, namespace);
}, },
}); });
@ -359,7 +360,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const testEnvironment = useMutation({ const testEnvironment = useMutation({
mutationFn: async () => { mutationFn: async () => {
if (!selectedCompanyId) { 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, { return agentsApi.testEnvironment(selectedCompanyId, adapterType, {
adapterConfig: buildAdapterConfigForTest(), adapterConfig: buildAdapterConfigForTest(),

View file

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

View file

@ -1,4 +1,5 @@
import React, { useCallback, useMemo, useState } from "react"; import React, { useCallback, useMemo, useState } from "react";
import { VOCAB } from "@paperclipai/branding";
import { import {
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
@ -494,7 +495,7 @@ const SecretField = React.memo(({
label={label} label={label}
description={ description={
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} required={isRequired}
error={error} error={error}

View file

@ -20,7 +20,7 @@ import { useDialog } from "../context/DialogContext";
import { usePanel } from "../context/PanelContext"; import { usePanel } from "../context/PanelContext";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { useSidebar } from "../context/SidebarContext"; import { useSidebar } from "../context/SidebarContext";
import { useTheme } from "../context/ThemeContext"; import { useTheme, THEME_META } from "../context/ThemeContext";
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts"; import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory"; import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
import { healthApi } from "../api/health"; import { healthApi } from "../api/health";
@ -59,6 +59,12 @@ export function Layout() {
setSelectedCompanyId, setSelectedCompanyId,
} = useCompany(); } = useCompany();
const { theme, toggleTheme } = useTheme(); 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 { companyPrefix } = useParams<{ companyPrefix: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
@ -67,7 +73,7 @@ export function Layout() {
const lastMainScrollTop = useRef(0); const lastMainScrollTop = useRef(0);
const [mobileNavVisible, setMobileNavVisible] = useState(true); const [mobileNavVisible, setMobileNavVisible] = useState(true);
const [instanceSettingsTarget, setInstanceSettingsTarget] = useState<string>(() => readRememberedInstanceSettingsPath()); const [instanceSettingsTarget, setInstanceSettingsTarget] = useState<string>(() => readRememberedInstanceSettingsPath());
const nextTheme = theme === "dark" ? "light" : "dark"; const isDarkTheme = THEME_META[theme].dark;
const matchedCompany = useMemo(() => { const matchedCompany = useMemo(() => {
if (!companyPrefix) return null; if (!companyPrefix) return null;
const requestedPrefix = companyPrefix.toUpperCase(); const requestedPrefix = companyPrefix.toUpperCase();
@ -331,10 +337,10 @@ export function Layout() {
size="icon-sm" size="icon-sm"
className="text-muted-foreground shrink-0" className="text-muted-foreground shrink-0"
onClick={toggleTheme} onClick={toggleTheme}
aria-label={`Switch to ${nextTheme} mode`} aria-label={`Switch to ${nextThemeLabel}`}
title={`Switch to ${nextTheme} mode`} 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> </Button>
</div> </div>
</div> </div>
@ -389,10 +395,10 @@ export function Layout() {
size="icon-sm" size="icon-sm"
className="text-muted-foreground shrink-0" className="text-muted-foreground shrink-0"
onClick={toggleTheme} onClick={toggleTheme}
aria-label={`Switch to ${nextTheme} mode`} aria-label={`Switch to ${nextThemeLabel}`}
title={`Switch to ${nextTheme} mode`} 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> </Button>
</div> </div>
</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 Markdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { useTheme } from "../context/ThemeContext"; import { useTheme, THEME_META } from "../context/ThemeContext";
import { mentionChipInlineStyle, parseMentionChipHref } from "../lib/mention-chips"; import { mentionChipInlineStyle, parseMentionChipHref } from "../lib/mention-chips";
interface MarkdownBodyProps { interface MarkdownBodyProps {
@ -97,7 +97,7 @@ export function MarkdownBody({ children, className, resolveImageSrc }: MarkdownB
pre: ({ node: _node, children: preChildren, ...preProps }) => { pre: ({ node: _node, children: preChildren, ...preProps }) => {
const mermaidSource = extractMermaidSource(preChildren); const mermaidSource = extractMermaidSource(preChildren);
if (mermaidSource) { if (mermaidSource) {
return <MermaidDiagramBlock source={mermaidSource} darkMode={theme === "dark"} />; return <MermaidDiagramBlock source={mermaidSource} darkMode={THEME_META[theme].dark} />;
} }
return <pre {...preProps}>{preChildren}</pre>; return <pre {...preProps}>{preChildren}</pre>;
}, },
@ -140,7 +140,7 @@ export function MarkdownBody({ children, className, resolveImageSrc }: MarkdownB
<div <div
className={cn( className={cn(
"paperclip-markdown prose prose-sm max-w-none break-words overflow-hidden", "paperclip-markdown prose prose-sm max-w-none break-words overflow-hidden",
theme === "dark" && "prose-invert", THEME_META[theme].dark && "prose-invert",
className, className,
)} )}
> >

View file

@ -351,7 +351,7 @@ export function OnboardingWizard() {
): Promise<AdapterEnvironmentTestResult | null> { ): Promise<AdapterEnvironmentTestResult | null> {
if (!createdCompanyId) { if (!createdCompanyId) {
setAdapterEnvError( setAdapterEnvError(
"Create or select a company before testing adapter environment." `Create or select a ${VOCAB.company.toLowerCase()} before testing adapter environment.`
); );
return null; return null;
} }
@ -407,7 +407,7 @@ export function OnboardingWizard() {
setStep(2); setStep(2);
} catch (err) { } 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 { } finally {
setLoading(false); setLoading(false);
} }
@ -715,7 +715,7 @@ export function OnboardingWizard() {
</label> </label>
<textarea <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]" 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} value={companyGoal}
onChange={(e) => setCompanyGoal(e.target.value)} onChange={(e) => setCompanyGoal(e.target.value)}
/> />
@ -1051,7 +1051,7 @@ export function OnboardingWizard() {
<p className="text-[11px] text-amber-900/90 leading-relaxed"> <p className="text-[11px] text-amber-900/90 leading-relaxed">
Claude failed while{" "} Claude failed while{" "}
<span className="font-mono">ANTHROPIC_API_KEY</span>{" "} <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. and retry the probe.
</p> </p>
<Button <Button

View file

@ -1,4 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { VOCAB } from "@paperclipai/branding";
import { Link } from "@/lib/router"; import { Link } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Project } from "@paperclipai/shared"; import type { Project } from "@paperclipai/shared";
@ -687,7 +688,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
{codebase.effectiveLocalFolder} {codebase.effectiveLocalFolder}
</div> </div>
{codebase.origin === "managed_checkout" && ( {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>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@ -719,7 +720,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
{hasAdditionalLegacyWorkspaces && ( {hasAdditionalLegacyWorkspaces && (
<div className="text-[11px] text-muted-foreground"> <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> </div>
)} )}

View file

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

View file

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

View file

@ -1,4 +1,5 @@
import { useState, useRef, useEffect, useCallback } from "react"; import { useState, useRef, useEffect, useCallback } from "react";
import { VOCAB } from "@paperclipai/branding";
import { import {
Tooltip, Tooltip,
TooltipTrigger, TooltipTrigger,
@ -33,7 +34,7 @@ export const help: Record<string, string> = {
dangerouslySkipPermissions: "Run unattended by auto-approving adapter permission prompts when supported.", dangerouslySkipPermissions: "Run unattended by auto-approving adapter permission prompts when supported.",
dangerouslyBypassSandbox: "Run Codex without sandbox restrictions. Required for filesystem/network access.", dangerouslyBypassSandbox: "Run Codex without sandbox restrictions. Required for filesystem/network access.",
search: "Enable Codex web search capability during runs.", 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.", 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}}.", 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.", 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.", args: "Command-line arguments, comma-separated.",
extraArgs: "Extra CLI arguments for local adapters, 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.", 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.", 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 Paperclip adds its standard wake and workspace fields.", 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.", 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.", heartbeatInterval: "Run this agent automatically on a timer. Useful for periodic tasks like checking for new work.",
intervalSec: "Seconds between automatic heartbeat invocations.", intervalSec: "Seconds between automatic heartbeat invocations.",

View file

@ -8,7 +8,16 @@ import {
type ReactNode, type ReactNode,
} from "react"; } 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 { interface ThemeContextValue {
theme: Theme; theme: Theme;
@ -17,36 +26,47 @@ interface ThemeContextValue {
} }
const THEME_STORAGE_KEY = "paperclip.theme"; const THEME_STORAGE_KEY = "paperclip.theme";
const DARK_THEME_COLOR = "#18181b";
const LIGHT_THEME_COLOR = "#ffffff";
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined); const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
function resolveThemeFromDocument(): Theme { function isValidTheme(value: string | null): value is Theme {
if (typeof document === "undefined") return "dark"; return value !== null && VALID_THEMES.includes(value as Theme);
return document.documentElement.classList.contains("dark") ? "dark" : "light"; }
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) { function applyTheme(theme: Theme) {
if (typeof document === "undefined") return; if (typeof document === "undefined") return;
const isDark = theme === "dark"; const meta = THEME_META[theme];
const root = document.documentElement; const root = document.documentElement;
root.classList.toggle("dark", isDark); root.classList.toggle("dark", meta.dark);
root.style.colorScheme = isDark ? "dark" : "light"; root.classList.toggle("theme-tokyo-night", theme === "tokyo-night");
root.style.colorScheme = meta.dark ? "dark" : "light";
const themeColorMeta = document.querySelector('meta[name="theme-color"]'); const themeColorMeta = document.querySelector('meta[name="theme-color"]');
if (themeColorMeta instanceof HTMLMetaElement) { 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 }) { export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => resolveThemeFromDocument()); const [theme, setThemeState] = useState<Theme>(() => readStoredTheme());
const setTheme = useCallback((nextTheme: Theme) => { const setTheme = useCallback((nextTheme: Theme) => {
setThemeState(nextTheme); setThemeState(nextTheme);
}, []); }, []);
const toggleTheme = useCallback(() => { 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(() => { useEffect(() => {
@ -54,7 +74,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
try { try {
localStorage.setItem(THEME_STORAGE_KEY, theme); localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch { } catch {
// Ignore local storage write failures in restricted environments. // Ignore localStorage write failures in restricted environments.
} }
}, [theme]); }, [theme]);

View file

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

View file

@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { VOCAB } from "@paperclipai/branding";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { activityApi } from "../api/activity"; import { activityApi } from "../api/activity";
import { agentsApi } from "../api/agents"; import { agentsApi } from "../api/agents";
@ -82,7 +83,7 @@ export function Activity() {
}, [issues]); }, [issues]);
if (!selectedCompanyId) { 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) { if (isLoading) {

View file

@ -925,7 +925,7 @@ export function AgentDetail() {
{actionError && <p className="text-sm text-destructive">{actionError}</p>} {actionError && <p className="text-sm text-destructive">{actionError}</p>}
{isPendingApproval && ( {isPendingApproval && (
<p className="text-sm text-amber-500"> <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> </p>
)} )}
@ -1479,11 +1479,11 @@ function ConfigurationTab({
const taskAssignLocked = agent.role === "ceo" || canCreateAgents; const taskAssignLocked = agent.role === "ceo" || canCreateAgents;
const taskAssignHint = const taskAssignHint =
taskAssignSource === "ceo_role" taskAssignSource === "ceo_role"
? "Enabled automatically for CEO agents." ? `Enabled automatically for ${VOCAB.ceo} agents.`
: taskAssignSource === "agent_creator" : taskAssignSource === "agent_creator"
? "Enabled automatically while this agent can create new agents." ? "Enabled automatically while this agent can create new agents."
: taskAssignSource === "explicit_grant" : taskAssignSource === "explicit_grant"
? "Enabled via explicit company permission grant." ? `Enabled via explicit ${VOCAB.company.toLowerCase()} permission grant.`
: "Disabled unless explicitly granted."; : "Disabled unless explicitly granted.";
return ( return (
@ -1727,7 +1727,7 @@ function PromptsTab({
const uploadMarkdownImage = useMutation({ const uploadMarkdownImage = useMutation({
mutationFn: async ({ file, namespace }: { file: File; namespace: string }) => { 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); return assetsApi.uploadImage(selectedCompanyId, file, namespace);
}, },
}); });
@ -1927,7 +1927,7 @@ function PromptsTab({
<HelpCircle className="h-3 w-3 text-muted-foreground cursor-help" /> <HelpCircle className="h-3 w-3 text-muted-foreground cursor-help" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right" sideOffset={4}> <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> </TooltipContent>
</Tooltip> </Tooltip>
</span> </span>
@ -1982,7 +1982,7 @@ function PromptsTab({
<HelpCircle className="h-3 w-3 text-muted-foreground cursor-help" /> <HelpCircle className="h-3 w-3 text-muted-foreground cursor-help" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right" sideOffset={4}> <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> </TooltipContent>
</Tooltip> </Tooltip>
</span> </span>
@ -2512,9 +2512,9 @@ function AgentSkillsTab({
const unsupportedSkillMessage = useMemo(() => { const unsupportedSkillMessage = useMemo(() => {
if (skillSnapshot?.mode !== "unsupported") return null; if (skillSnapshot?.mode !== "unsupported") return null;
if (agent.adapterType === "openclaw_gateway") { 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]); }, [agent.adapterType, skillSnapshot?.mode]);
const hasUnsavedChanges = !arraysEqual(skillDraft, lastSavedSkills); const hasUnsavedChanges = !arraysEqual(skillDraft, lastSavedSkills);
const saveStatusLabel = syncSkills.isPending const saveStatusLabel = syncSkills.isPending
@ -2672,7 +2672,7 @@ function AgentSkillsTab({
<section className="border-y border-border"> <section className="border-y border-border">
<div className="border-b border-border bg-muted/40 px-3 py-2"> <div className="border-b border-border bg-muted/40 px-3 py-2">
<span className="text-xs font-medium text-muted-foreground"> <span className="text-xs font-medium text-muted-foreground">
Required by Paperclip {`Required by ${VOCAB.appName}`}
</span> </span>
</div> </div>
{requiredSkillRows.map(renderSkillRow)} {requiredSkillRows.map(renderSkillRow)}
@ -2683,7 +2683,7 @@ function AgentSkillsTab({
<section className="border-y border-border"> <section className="border-y border-border">
<div className="border-b border-border bg-muted/40 px-3 py-2"> <div className="border-b border-border bg-muted/40 px-3 py-2">
<span className="text-xs font-medium text-muted-foreground"> <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> </span>
</div> </div>
{unmanagedSkillRows.map(renderSkillRow)} {unmanagedSkillRows.map(renderSkillRow)}
@ -2695,7 +2695,7 @@ function AgentSkillsTab({
{desiredOnlyMissingSkills.length > 0 && ( {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="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"> <div className="mt-1 text-xs">
{desiredOnlyMissingSkills.join(", ")} {desiredOnlyMissingSkills.join(", ")}
</div> </div>
@ -3969,7 +3969,7 @@ function KeysTab({ agentId, companyId }: { agentId: string; companyId?: string }
Create API Key Create API Key
</h3> </h3>
<p className="text-xs text-muted-foreground"> <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> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input

View file

@ -1,4 +1,5 @@
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo } from "react";
import { VOCAB } from "@paperclipai/branding";
import { Link, useNavigate, useLocation } from "@/lib/router"; import { Link, useNavigate, useLocation } from "@/lib/router";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { agentsApi, type OrgNode } from "../api/agents"; import { agentsApi, type OrgNode } from "../api/agents";
@ -122,7 +123,7 @@ export function Agents() {
}, [setBreadcrumbs]); }, [setBreadcrumbs]);
if (!selectedCompanyId) { 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) { if (isLoading) {

View file

@ -1,4 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { VOCAB } from "@paperclipai/branding";
import { useNavigate, useLocation } from "@/lib/router"; import { useNavigate, useLocation } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { approvalsApi } from "../api/approvals"; import { approvalsApi } from "../api/approvals";
@ -75,7 +76,7 @@ export function Approvals() {
).length; ).length;
if (!selectedCompanyId) { 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) { if (isLoading) {

View file

@ -143,7 +143,7 @@ export function CliAuthPage() {
</div> </div>
{challenge.requestedCompanyName && ( {challenge.requestedCompanyName && (
<div> <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 className="text-foreground">{challenge.requestedCompanyName}</div>
</div> </div>
)} )}

View file

@ -70,7 +70,7 @@ export function Companies() {
}); });
useEffect(() => { useEffect(() => {
setBreadcrumbs([{ label: "Companies" }]); setBreadcrumbs([{ label: VOCAB.companies }]);
}, [setBreadcrumbs]); }, [setBreadcrumbs]);
function startEdit(companyId: string, currentName: string) { function startEdit(companyId: string, currentName: string) {
@ -98,7 +98,7 @@ export function Companies() {
</div> </div>
<div className="h-6"> <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>} {error && <p className="text-sm text-destructive">{error.message}</p>}
</div> </div>
@ -267,7 +267,7 @@ export function Companies() {
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<p className="text-sm text-destructive font-medium"> <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> </p>
<div className="flex items-center gap-2 ml-4 shrink-0"> <div className="flex items-center gap-2 ml-4 shrink-0">
<Button <Button

View file

@ -912,7 +912,7 @@ export function CompanyExport() {
} }
if (!selectedCompanyId) { 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) { if (exportPreviewMutation.isPending && !exportData) {

View file

@ -704,7 +704,7 @@ export function CompanyImport() {
}, [companyAgents]); }, [companyAgents]);
const localZipHelpText = 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(() => { useEffect(() => {
setBreadcrumbs([ setBreadcrumbs([
@ -1086,7 +1086,7 @@ export function CompanyImport() {
const selectedAction = selectedFile ? (actionMap.get(selectedFile) ?? null) : null; const selectedAction = selectedFile ? (actionMap.get(selectedFile) ?? null) : null;
if (!selectedCompanyId) { 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 ( return (
@ -1096,7 +1096,7 @@ export function CompanyImport() {
<div> <div>
<h2 className="text-base font-semibold">Import source</h2> <h2 className="text-base font-semibold">Import source</h2>
<p className="text-xs text-muted-foreground mt-1"> <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> </p>
</div> </div>
@ -1178,7 +1178,7 @@ export function CompanyImport() {
</Field> </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 <select
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none" className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
value={targetMode} value={targetMode}
@ -1187,16 +1187,16 @@ export function CompanyImport() {
setImportPreview(null); setImportPreview(null);
}} }}
> >
<option value="new">Create new company</option> <option value="new">{`Create new ${VOCAB.company.toLowerCase()}`}</option>
<option value="existing"> <option value="existing">
Existing company: {selectedCompany?.name} {`Existing ${VOCAB.company.toLowerCase()}: ${selectedCompany?.name}`}
</option> </option>
</select> </select>
</Field> </Field>
{targetMode === "new" && ( {targetMode === "new" && (
<Field <Field
label="New company name" label={`New ${VOCAB.company.toLowerCase()} name`}
hint="Optional override. Leave blank to use the package name." hint="Optional override. Leave blank to use the package name."
> >
<input <input
@ -1211,7 +1211,7 @@ export function CompanyImport() {
<Field <Field
label="Collision strategy" 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 <select
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none" 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) { if (!selectedCompany) {
return ( return (
<div className="text-sm text-muted-foreground"> <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> </div>
); );
} }

View file

@ -989,7 +989,7 @@ export function CompanySkills() {
}); });
if (!selectedCompanyId) { 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() { function handleAddSkillSource() {

View file

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

View file

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

View file

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

View file

@ -1,4 +1,5 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { VOCAB } from "@paperclipai/branding";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { goalsApi } from "../api/goals"; import { goalsApi } from "../api/goals";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
@ -27,7 +28,7 @@ export function Goals() {
}); });
if (!selectedCompanyId) { 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) { if (isLoading) {

View file

@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { VOCAB } from "@paperclipai/branding";
import { Link, useLocation, useNavigate } from "@/lib/router"; import { Link, useLocation, useNavigate } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { approvalsApi } from "../api/approvals"; import { approvalsApi } from "../api/approvals";
@ -909,7 +910,7 @@ export function Inbox() {
}; };
if (!selectedCompanyId) { 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; 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 { SlidersHorizontal } from "lucide-react";
import { instanceSettingsApi } from "@/api/instanceSettings"; import { instanceSettingsApi } from "@/api/instanceSettings";
import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useTheme, THEME_META, type Theme } from "../context/ThemeContext";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
const ORDERED_THEMES: Theme[] = ["catppuccin-mocha", "tokyo-night", "catppuccin-latte"];
export function InstanceGeneralSettings() { export function InstanceGeneralSettings() {
const { setBreadcrumbs } = useBreadcrumbs(); const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [actionError, setActionError] = useState<string | null>(null); const [actionError, setActionError] = useState<string | null>(null);
const { theme, setTheme } = useTheme();
useEffect(() => { useEffect(() => {
setBreadcrumbs([ setBreadcrumbs([
@ -69,6 +73,37 @@ export function InstanceGeneralSettings() {
</div> </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"> <section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="space-y-1.5"> <div className="space-y-1.5">

View file

@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { VOCAB } from "@paperclipai/branding";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Clock3, ExternalLink, Settings } from "lucide-react"; import { Clock3, ExternalLink, Settings } from "lucide-react";
import type { InstanceSchedulerHeartbeatAgent } from "@paperclipai/shared"; import type { InstanceSchedulerHeartbeatAgent } from "@paperclipai/shared";
@ -171,14 +172,14 @@ export function InstanceSettings() {
<h1 className="text-lg font-semibold">Scheduler Heartbeats</h1> <h1 className="text-lg font-semibold">Scheduler Heartbeats</h1>
</div> </div>
<p className="text-sm text-muted-foreground"> <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> </p>
</div> </div>
<div className="flex items-center gap-4 text-sm text-muted-foreground"> <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">{activeCount}</span> active</span>
<span><span className="font-semibold text-foreground">{disabledCount}</span> disabled</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 && ( {anyEnabled && (
<Button <Button
variant="destructive" variant="destructive"

View file

@ -1,4 +1,5 @@
import { useEffect, useMemo, useCallback } from "react"; import { useEffect, useMemo, useCallback } from "react";
import { VOCAB } from "@paperclipai/branding";
import { useLocation, useSearchParams } from "@/lib/router"; import { useLocation, useSearchParams } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { issuesApi } from "../api/issues"; import { issuesApi } from "../api/issues";
@ -93,7 +94,7 @@ export function Issues() {
}); });
if (!selectedCompanyId) { 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 ( return (

View file

@ -1,4 +1,5 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { VOCAB } from "@paperclipai/branding";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { issuesApi } from "../api/issues"; import { issuesApi } from "../api/issues";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
@ -27,7 +28,7 @@ export function MyIssues() {
}); });
if (!selectedCompanyId) { 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) { if (isLoading) {

View file

@ -331,7 +331,7 @@ export function NewAgent() {
{/* Footer */} {/* Footer */}
<div className="border-t border-border px-4 py-3"> <div className="border-t border-border px-4 py-3">
{isFirstAgent && ( {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 && ( {formError && (
<p className="text-xs text-destructive mb-2">{formError}</p> <p className="text-xs text-destructive mb-2">{formError}</p>

View file

@ -1,4 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { VOCAB } from "@paperclipai/branding";
import { Link } from "@/lib/router"; import { Link } from "@/lib/router";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { agentsApi, type OrgNode } from "../api/agents"; import { agentsApi, type OrgNode } from "../api/agents";
@ -104,7 +105,7 @@ export function Org() {
}); });
if (!selectedCompanyId) { 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) { if (isLoading) {

View file

@ -1,4 +1,5 @@
import { useEffect, useRef, useState, useMemo, useCallback } from "react"; import { useEffect, useRef, useState, useMemo, useCallback } from "react";
import { VOCAB } from "@paperclipai/branding";
import { Link, useNavigate } from "@/lib/router"; import { Link, useNavigate } from "@/lib/router";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { agentsApi, type OrgNode } from "../api/agents"; import { agentsApi, type OrgNode } from "../api/agents";
@ -256,7 +257,7 @@ export function OrgChart() {
}, [zoom, pan]); }, [zoom, pan]);
if (!selectedCompanyId) { 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) { if (isLoading) {

View file

@ -1,4 +1,5 @@
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { VOCAB } from "@paperclipai/branding";
import { Link, Navigate, useParams } from "@/lib/router"; import { Link, Navigate, useParams } from "@/lib/router";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useCompany } from "@/context/CompanyContext"; import { useCompany } from "@/context/CompanyContext";
@ -104,7 +105,7 @@ export function PluginPage() {
} }
return ( return (
<div className="space-y-4"> <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> </div>
); );
} }

View file

@ -1,4 +1,5 @@
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { VOCAB } from "@paperclipai/branding";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { projectsApi } from "../api/projects"; import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
@ -33,7 +34,7 @@ export function Projects() {
); );
if (!selectedCompanyId) { 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) { if (isLoading) {

View file

@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { VOCAB } from "@paperclipai/branding";
import { Link, useLocation, useNavigate, useParams } from "@/lib/router"; import { Link, useLocation, useNavigate, useParams } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
@ -402,7 +403,7 @@ export function RoutineDetail() {
onError: (error) => { onError: (error) => {
pushToast({ pushToast({
title: "Failed to save routine", 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", tone: "error",
}); });
}, },
@ -423,7 +424,7 @@ export function RoutineDetail() {
onError: (error) => { onError: (error) => {
pushToast({ pushToast({
title: "Routine run failed", 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", tone: "error",
}); });
}, },
@ -445,7 +446,7 @@ export function RoutineDetail() {
onError: (error) => { onError: (error) => {
pushToast({ pushToast({
title: "Failed to update routine", 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", tone: "error",
}); });
}, },
@ -486,7 +487,7 @@ export function RoutineDetail() {
onError: (error) => { onError: (error) => {
pushToast({ pushToast({
title: "Failed to add trigger", 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", tone: "error",
}); });
}, },
@ -504,7 +505,7 @@ export function RoutineDetail() {
onError: (error) => { onError: (error) => {
pushToast({ pushToast({
title: "Failed to update trigger", 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", tone: "error",
}); });
}, },
@ -522,7 +523,7 @@ export function RoutineDetail() {
onError: (error) => { onError: (error) => {
pushToast({ pushToast({
title: "Failed to delete trigger", 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", tone: "error",
}); });
}, },
@ -544,7 +545,7 @@ export function RoutineDetail() {
onError: (error) => { onError: (error) => {
pushToast({ pushToast({
title: "Failed to rotate webhook secret", 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", tone: "error",
}); });
}, },
@ -584,7 +585,7 @@ export function RoutineDetail() {
const currentProject = editDraft.projectId ? projectById.get(editDraft.projectId) ?? null : null; const currentProject = editDraft.projectId ? projectById.get(editDraft.projectId) ?? null : null;
if (!selectedCompanyId) { 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) { 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 className="rounded-lg border border-blue-500/30 bg-blue-500/5 p-4 space-y-3 text-sm">
<div> <div>
<p className="font-medium">{secretMessage.title}</p> <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>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-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; const currentProject = draft.projectId ? projectById.get(draft.projectId) ?? null : null;
if (!selectedCompanyId) { 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) { if (isLoading) {

View file

@ -6,13 +6,20 @@ import tailwindcss from "@tailwindcss/vite";
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
resolve: { resolve: {
alias: { alias: [
"@": path.resolve(__dirname, "./src"), { find: "@", replacement: path.resolve(__dirname, "./src") },
lexical: path.resolve(__dirname, "./node_modules/lexical/Lexical.mjs"), {
// [nexus] Replace upstream OnboardingWizard with Nexus single-step version find: "lexical",
[path.resolve(__dirname, "src/components/OnboardingWizard")]: replacement: path.resolve(__dirname, "./node_modules/lexical/Lexical.mjs"),
path.resolve(__dirname, "./src/components/NexusOnboardingWizard"), },
}, // [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: { server: {
port: 5173, port: 5173,