-
+
);
}
diff --git a/src/pages/legal/LegalPage.tsx b/src/pages/legal/LegalPage.tsx
new file mode 100644
index 0000000..66d2651
--- /dev/null
+++ b/src/pages/legal/LegalPage.tsx
@@ -0,0 +1,128 @@
+import type { JSX } from "react";
+import { Link, useLocation } from "react-router";
+import { LockKeyhole, ScrollText, ShieldCheck } from "lucide-react";
+import { MainLayout } from "@/layouts/MainLayout";
+
+const privacySections = [
+ {
+ body: "Forge is designed as a local-first developer workstation. Tool input such as JWTs, secrets, payloads, source snippets, encoded text, and generated values is processed in your browser whenever the tool supports local execution.",
+ title: "Local-first processing",
+ },
+ {
+ body: "Forge may store tool drafts, theme preference, sidebar preference, and recent workspace state in browser storage on this device. This keeps workflows convenient without requiring an account.",
+ title: "Browser storage",
+ },
+ {
+ body: "Do not paste production secrets unless you understand the environment you are running Forge in. If you deploy Forge yourself, your hosting, telemetry, proxy, browser extension, or network policies may affect privacy.",
+ title: "Sensitive content",
+ },
+ {
+ body: "Support links may open email, GitHub, or LinkedIn. Information you choose to send through those services is handled by those providers and by the repository maintainers receiving it.",
+ title: "External services",
+ },
+];
+
+const termsSections = [
+ {
+ body: "Forge is provided as a developer utility. You are responsible for checking outputs before using them in production systems, security-sensitive flows, or customer-facing environments.",
+ title: "Use at your own discretion",
+ },
+ {
+ body: "Generated passwords, JWT secrets, UUIDs, hashes, timestamps, encodings, and conversions should be validated against your own security, compliance, and application requirements.",
+ title: "Generated output",
+ },
+ {
+ body: "Forge is not a legal, compliance, cryptography, or security audit service. Treat it as a local tool that helps you inspect and transform data faster.",
+ title: "No professional advice",
+ },
+ {
+ body: "When you report issues or share feedback, avoid posting private tokens, customer data, credentials, or proprietary source code in public GitHub issues.",
+ title: "Responsible reporting",
+ },
+];
+
+export function LegalPage(): JSX.Element {
+ const location = useLocation();
+ const isTerms = location.pathname === "/terms";
+ const sections = isTerms ? termsSections : privacySections;
+
+ return (
+
+
+
+
+
+ {isTerms ? (
+
+ ) : (
+
+ )}
+
+
+
+ {isTerms ? "Practical terms for practical tools" : "Private by default"}
+
+
+ {isTerms
+ ? "Forge is built to make everyday developer work calmer and faster, but it does not replace careful review of security-sensitive output."
+ : "Forge is most useful with sensitive text because its core tools are intended to run locally, close to your browser session and away from unnecessary accounts."}
+
+
+
+
+
+ {sections.map((section) => (
+
+
+ {section.title}
+
+
+ {section.body}
+
+
+ ))}
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/settings/SettingsPage.tsx b/src/pages/settings/SettingsPage.tsx
index 1d888f3..568d35d 100644
--- a/src/pages/settings/SettingsPage.tsx
+++ b/src/pages/settings/SettingsPage.tsx
@@ -1,35 +1,176 @@
import type { JSX } from "react";
-import { MonitorCog } from "lucide-react";
+import {
+ BookOpen,
+ Database,
+ ExternalLink,
+ LifeBuoy,
+ MonitorCog,
+ PanelLeftClose,
+ PanelLeftOpen,
+ Paintbrush,
+ Star,
+} from "lucide-react";
+import { toolRegistry } from "@/core/registry/tool.registry";
+import { useWorkspaceStore } from "@/core/workspace/workspace.store";
import { MainLayout } from "@/layouts/MainLayout";
import { Badge } from "@/shared/ui/badge";
+import { Button } from "@/shared/ui/button";
+
+const persistedTools = toolRegistry.filter((tool) => tool.persist);
export function SettingsPage(): JSX.Element {
+ const activeToolId = useWorkspaceStore((state) => state.activeToolId);
+ const favoriteToolIds = useWorkspaceStore((state) => state.favoriteToolIds);
+ const recentToolIds = useWorkspaceStore((state) => state.recentToolIds);
+ const sidebarCollapsed = useWorkspaceStore((state) => state.sidebarCollapsed);
+ const toggleSidebar = useWorkspaceStore((state) => state.toggleSidebar);
+ const activeTool = toolRegistry.find((tool) => tool.id === activeToolId);
+
return (
-
-
-
-
+
+
+
+
+ {sidebarCollapsed ? (
+
+ ) : (
+
+ )}
+
+
+
+
Navigation
+ {sidebarCollapsed ? "Collapsed" : "Expanded"}
+
+
+ The left navigation is the product map for Forge. Keep it open while
+ exploring tools, or collapse it when you want more editor room.
+
+
+
+
+
+
+
+
+
+
+
+
Appearance
+ Header menu
+
+
+ Light, Dark, and System are controlled from the header. That keeps
+ appearance available across tools without burying it inside Settings.
+
+
-
-
+
+
+
+
+
+
+
+
+
Local workspace
+
+ Forge keeps tool drafts and UI preferences in this browser. It does not
+ require an account or send workspace state to a server.
+
+
+ }
+ label="Active tool"
+ value={activeTool?.name ?? "None"}
+ />
+ }
+ label="Favorites"
+ value={favoriteToolIds.length.toString()}
+ />
+ }
+ label="Persisted tools"
+ value={persistedTools.length.toString()}
+ />
+
+
+ Recent tools tracked: {recentToolIds.length}
+
+
+
+
+
+
+
+
+
+
+
- Preferences foundation
+ Help and documentation
-
Planned
+
+ Use docs for product conventions and GitHub issues for bugs, requests, or
+ reproducible tool problems.
+
+
-
- The white-first visual system is active. Dark mode, workspace persistence,
- custom shortcuts, and import/export settings will be added in later
- milestones.
-
-
+
);
}
+
+function StatCard({
+ icon,
+ label,
+ value,
+}: {
+ icon: JSX.Element;
+ label: string;
+ value: string;
+}): JSX.Element {
+ return (
+
+
+ {icon}
+ {label}
+
+
{value}
+
+ );
+}
diff --git a/src/pages/support/SupportPage.tsx b/src/pages/support/SupportPage.tsx
new file mode 100644
index 0000000..7f391b6
--- /dev/null
+++ b/src/pages/support/SupportPage.tsx
@@ -0,0 +1,260 @@
+import type { JSX } from "react";
+import { useEffect, useMemo, useState } from "react";
+import {
+ AlertTriangle,
+ Github,
+ HeartHandshake,
+ Linkedin,
+ Mail,
+ MessageCircle,
+} from "lucide-react";
+import { NavLink, useParams } from "react-router";
+import { cn } from "@/shared/lib/cn";
+
+const supportModes = [
+ {
+ description: "Ask how a tool should work, clarify a workflow, or request guidance.",
+ icon: MessageCircle,
+ id: "ask",
+ title: "Ask a question",
+ },
+ {
+ description: "Report broken behavior, incorrect output, or UI regressions.",
+ icon: AlertTriangle,
+ id: "issue",
+ title: "Report an issue",
+ },
+ {
+ description: "Suggest improvements, missing tools, or better defaults.",
+ icon: HeartHandshake,
+ id: "feedback",
+ title: "Share feedback",
+ },
+];
+
+const contact = {
+ email: "cuthanhcam04@gmail.com",
+ github: "https://github.com/cuthanhcam",
+ issues: "https://github.com/orcace/forge/issues",
+ linkedin: "https://www.linkedin.com/in/cuthanhcam/",
+ repository: "https://github.com/orcace/forge",
+};
+
+export function SupportPage(): JSX.Element {
+ const { mode } = useParams();
+ const activeMode = supportModes.find((item) => item.id === mode) ?? supportModes[0];
+ const ActiveIcon = activeMode.icon;
+ const [subject, setSubject] = useState(`Forge - ${activeMode.title}`);
+ const [message, setMessage] = useState("");
+ const mailtoHref = useMemo(() => {
+ const body = message || defaultMessage(activeMode.id);
+
+ return `mailto:${contact.email}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
+ }, [activeMode.id, message, subject]);
+
+ useEffect(() => {
+ setSubject(`Forge - ${activeMode.title}`);
+ setMessage("");
+ }, [activeMode.title]);
+
+ return (
+
+
+
+
+
+ Support
+
+
+ Keep the work moving without leaving the product context.
+
+
+ Use the path that matches what you need: ask a question, report an issue, or
+ share feedback for the next Forge iteration.
+
+
+
+
+
+
+
+
+
+
+
+ {activeMode.title}
+
+
+ {activeMode.description}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Compose an email
+
+
+ Write a quick note here, then open it in your mail app. For
+ reproducible bugs, GitHub issues are better because they stay
+ trackable in the real repository.
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+interface ContactCardProps {
+ description: string;
+ href: string;
+ icon: typeof Mail;
+ label: string;
+ title: string;
+}
+
+function ContactCard({
+ description,
+ href,
+ icon: Icon,
+ label,
+ title,
+}: ContactCardProps): JSX.Element {
+ return (
+
+
+
+
+ {title}
+
+ {label}
+
+ {description}
+
+ );
+}
+
+function defaultMessage(mode: string): string {
+ if (mode === "issue") {
+ return "Tool or page:\nSteps to reproduce:\nExpected result:\nActual result:\nEnvironment:";
+ }
+
+ if (mode === "feedback") {
+ return "Workflow:\nWhat feels unclear or slow:\nSuggested improvement:";
+ }
+
+ return "Question:\nContext:\nTool or page:\nWhat I tried:";
+}
diff --git a/src/widgets/command-palette/CommandPalette.tsx b/src/widgets/command-palette/CommandPalette.tsx
index 5e51c0d..39f15a9 100644
--- a/src/widgets/command-palette/CommandPalette.tsx
+++ b/src/widgets/command-palette/CommandPalette.tsx
@@ -110,7 +110,7 @@ export function CommandPalette({
-
+
{results.length > 0 ? (
results.map((result, index) => (
void;
+ onOpenShortcuts: () => void;
}
-export function Header({ onOpenCommandPalette }: HeaderProps): JSX.Element {
+export function Header({
+ onOpenCommandPalette,
+ onOpenShortcuts,
+}: HeaderProps): JSX.Element {
const location = useLocation();
const headerMeta = getHeaderMeta(location.pathname);
const HeaderIcon = headerMeta.icon;
@@ -32,7 +36,10 @@ export function Header({ onOpenCommandPalette }: HeaderProps): JSX.Element {
-
+
);
}
@@ -49,10 +56,26 @@ function getHeaderMeta(pathname: string): {
return { title: "Settings" };
}
+ if (pathname === "/privacy") {
+ return { title: "Privacy" };
+ }
+
+ if (pathname === "/terms") {
+ return { title: "Terms of Use" };
+ }
+
if (pathname === "/docs") {
return { title: "Documentation" };
}
+ if (pathname.startsWith("/docs/")) {
+ return { title: "Documentation" };
+ }
+
+ if (pathname.startsWith("/support")) {
+ return { title: "Support" };
+ }
+
const toolId = pathname.match(/^\/tools\/([^/]+)$/)?.[1];
const tool = toolId ? getToolById(toolId) : undefined;
diff --git a/src/widgets/header/HeaderActions.tsx b/src/widgets/header/HeaderActions.tsx
index 5e91930..309647b 100644
--- a/src/widgets/header/HeaderActions.tsx
+++ b/src/widgets/header/HeaderActions.tsx
@@ -1,13 +1,18 @@
import type { JSX } from "react";
-import { Github, Search } from "lucide-react";
+import { Github, Keyboard, Search } from "lucide-react";
import { shortcuts } from "@/core/shortcuts/shortcuts";
import { Button } from "@/shared/ui/button";
+import { ThemeMenu } from "./ThemeMenu";
interface HeaderActionsProps {
onOpenCommandPalette: () => void;
+ onOpenShortcuts: () => void;
}
-export function HeaderActions({ onOpenCommandPalette }: HeaderActionsProps): JSX.Element {
+export function HeaderActions({
+ onOpenCommandPalette,
+ onOpenShortcuts,
+}: HeaderActionsProps): JSX.Element {
return (
+
+
diff --git a/src/widgets/header/ThemeMenu.tsx b/src/widgets/header/ThemeMenu.tsx
new file mode 100644
index 0000000..9b73d1c
--- /dev/null
+++ b/src/widgets/header/ThemeMenu.tsx
@@ -0,0 +1,115 @@
+import type { JSX } from "react";
+import { useEffect, useRef, useState } from "react";
+import { Check, ChevronDown, Monitor, Moon, Sun } from "lucide-react";
+import { useThemeStore } from "@/core/theme/theme.store";
+import type { ThemeMode } from "@/core/theme/theme.types";
+
+const themeOptions: Array<{
+ icon: typeof Sun;
+ label: string;
+ mode: ThemeMode;
+}> = [
+ {
+ icon: Sun,
+ label: "Light",
+ mode: "light",
+ },
+ {
+ icon: Moon,
+ label: "Dark",
+ mode: "dark",
+ },
+ {
+ icon: Monitor,
+ label: "System",
+ mode: "system",
+ },
+];
+
+function getThemeIcon(mode: ThemeMode): typeof Sun {
+ return themeOptions.find((option) => option.mode === mode)?.icon ?? Sun;
+}
+
+export function ThemeMenu(): JSX.Element {
+ const [open, setOpen] = useState(false);
+ const containerRef = useRef(null);
+ const mode = useThemeStore((state) => state.mode);
+ const setMode = useThemeStore((state) => state.setMode);
+ const ActiveIcon = getThemeIcon(mode);
+
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+
+ function handlePointerDown(event: PointerEvent): void {
+ if (!containerRef.current?.contains(event.target as Node)) {
+ setOpen(false);
+ }
+ }
+
+ function handleKeyDown(event: KeyboardEvent): void {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ setOpen(false);
+ }
+ }
+
+ window.addEventListener("pointerdown", handlePointerDown);
+ window.addEventListener("keydown", handleKeyDown);
+
+ return () => {
+ window.removeEventListener("pointerdown", handlePointerDown);
+ window.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [open]);
+
+ return (
+
+
+ {open ? (
+
+ {themeOptions.map((option) => {
+ const Icon = option.icon;
+ const selected = option.mode === mode;
+
+ return (
+
+ );
+ })}
+
+ ) : null}
+
+ );
+}
diff --git a/src/widgets/shortcuts/KeyboardShortcutsDialog.tsx b/src/widgets/shortcuts/KeyboardShortcutsDialog.tsx
new file mode 100644
index 0000000..06a26c0
--- /dev/null
+++ b/src/widgets/shortcuts/KeyboardShortcutsDialog.tsx
@@ -0,0 +1,125 @@
+import type { JSX } from "react";
+import { useEffect } from "react";
+import { X } from "lucide-react";
+
+interface KeyboardShortcutsDialogProps {
+ onOpenChange: (open: boolean) => void;
+ open: boolean;
+}
+
+const shortcutGroups = [
+ {
+ items: [{ keys: ["Ctrl", "K"], label: "Open command palette" }],
+ title: "General",
+ },
+ {
+ items: [
+ { keys: ["Esc"], label: "Close dialogs, menus, and flyouts" },
+ { keys: ["Up"], label: "Move to previous command palette result" },
+ { keys: ["Down"], label: "Move to next command palette result" },
+ { keys: ["Enter"], label: "Open selected command palette result" },
+ ],
+ title: "Navigation",
+ },
+ {
+ items: [
+ { keys: ["Ctrl", "A"], label: "Select editor content" },
+ { keys: ["Ctrl", "C"], label: "Copy selected content" },
+ { keys: ["Tab"], label: "Indent in JSON-style editors" },
+ ],
+ title: "Editors",
+ },
+];
+
+export function KeyboardShortcutsDialog({
+ onOpenChange,
+ open,
+}: KeyboardShortcutsDialogProps): JSX.Element | null {
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+
+ function handleKeyDown(event: KeyboardEvent): void {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ onOpenChange(false);
+ }
+ }
+
+ window.addEventListener("keydown", handleKeyDown);
+
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [onOpenChange, open]);
+
+ if (!open) {
+ return null;
+ }
+
+ return (
+
+
+ );
+}
diff --git a/src/widgets/sidebar/Sidebar.tsx b/src/widgets/sidebar/Sidebar.tsx
index 09f6629..1359cf2 100644
--- a/src/widgets/sidebar/Sidebar.tsx
+++ b/src/widgets/sidebar/Sidebar.tsx
@@ -4,16 +4,16 @@ import { Link, NavLink } from "react-router";
import {
AlertTriangle,
BookOpen,
- GraduationCap,
HeartHandshake,
Keyboard,
MessageCircle,
- Newspaper,
HelpCircle,
PanelLeftClose,
PanelLeftOpen,
Route,
+ ScrollText,
Settings,
+ ShieldCheck,
type LucideIcon,
} from "lucide-react";
import { toolCategoryDefinitions } from "@/core/registry/tool.categories";
@@ -22,7 +22,11 @@ import { useWorkspaceStore } from "@/core/workspace/workspace.store";
import { cn } from "@/shared/lib/cn";
import { SidebarGroup } from "./SidebarGroup";
-export function Sidebar(): JSX.Element {
+interface SidebarProps {
+ onOpenShortcuts: () => void;
+}
+
+export function Sidebar({ onOpenShortcuts }: SidebarProps): JSX.Element {
const [helpOpen, setHelpOpen] = useState(false);
const helpRef = useRef(null);
const sidebarCollapsed = useWorkspaceStore((state) => state.sidebarCollapsed);
@@ -58,16 +62,20 @@ export function Sidebar(): JSX.Element {
>
-

+
Forge
@@ -117,9 +125,24 @@ export function Sidebar(): JSX.Element {
Support
-
-
-
+
setHelpOpen(false)}
+ to="/support/ask"
+ />
+ setHelpOpen(false)}
+ href="https://github.com/orcace/forge/issues"
+ />
+ setHelpOpen(false)}
+ to="/support/feedback"
+ />
@@ -131,10 +154,35 @@ export function Sidebar(): JSX.Element {
onSelect={() => setHelpOpen(false)}
to="/docs"
/>
-
-
-
-
+ setHelpOpen(false)}
+ to="/docs/guides"
+ />
+ {
+ setHelpOpen(false);
+ onOpenShortcuts();
+ }}
+ />
+
+
+
Trust
+
setHelpOpen(false)}
+ to="/privacy"
+ />
+ setHelpOpen(false)}
+ to="/terms"
+ />
) : null}
@@ -205,12 +253,14 @@ export function Sidebar(): JSX.Element {
interface HelpMenuItemProps {
icon: LucideIcon;
+ href?: string;
label: string;
onSelect?: () => void;
to?: string;
}
function HelpMenuItem({
+ href,
icon: Icon,
label,
onSelect,
@@ -228,6 +278,21 @@ function HelpMenuItem({
);
}
+ if (href) {
+ return (
+
+
+ {label}
+
+ );
+ }
+
return (
diff --git a/tests/app.test.tsx b/tests/app.test.tsx
index f1cf254..5a4e7d7 100644
--- a/tests/app.test.tsx
+++ b/tests/app.test.tsx
@@ -9,10 +9,12 @@ describe("App", () => {
expect(
screen.getByRole("heading", {
- name: "Developer tools that behave like one product",
+ name: /Developer tools without the tab drift\./,
}),
).toBeInTheDocument();
- expect(screen.getByRole("link", { name: "JSON Formatter" })).toBeInTheDocument();
+ expect(
+ screen.getAllByRole("link", { name: "JSON Formatter" }).length,
+ ).toBeGreaterThan(0);
});
it("opens the command palette with the keyboard shortcut", async () => {
@@ -25,4 +27,28 @@ describe("App", () => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Search tools" })).toHaveFocus();
});
+
+ it("opens the keyboard shortcuts dialog", async () => {
+ const user = userEvent.setup();
+
+ render();
+
+ await user.click(screen.getByRole("button", { name: "View keyboard shortcuts" }));
+
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
+ expect(
+ screen.getByRole("heading", { name: "Keyboard shortcuts" }),
+ ).toBeInTheDocument();
+ });
+
+ it("closes the keyboard shortcuts dialog with escape", async () => {
+ const user = userEvent.setup();
+
+ render();
+
+ await user.click(screen.getByRole("button", { name: "View keyboard shortcuts" }));
+ await user.keyboard("{Escape}");
+
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ });
});