diff --git a/docs/superpowers/specs/2026-04-03-user-menu-design.md b/docs/superpowers/specs/2026-04-03-user-menu-design.md
new file mode 100644
index 0000000..33f5256
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-03-user-menu-design.md
@@ -0,0 +1,35 @@
+# User Menu Design
+
+## Summary
+
+Replace the plain "Sign out" button in the header with a user icon that opens a dropdown menu containing Settings and Sign out options. This provides a way to navigate to the Settings page from the header.
+
+## Components
+
+### UserMenu (`src/client/components/UserMenu.tsx`)
+
+New component rendered by `TotalsBar` when authenticated.
+
+**Trigger:** Circular `CircleUser` icon button (Lucide). Styled consistently with surrounding header elements.
+
+**Dropdown:** Absolutely-positioned popover anchored to the right edge, appearing below the icon:
+
+1. **Settings** — `Settings` (gear) icon + "Settings" label, ` `
+2. **Divider** — thin horizontal line
+3. **Sign out** — `LogOut` icon + "Sign out" label, calls `logout.mutate()` from `useLogout()`
+
+**Behavior:**
+- Click icon toggles open/close
+- Click outside closes (via `useEffect` with document click listener)
+- Clicking a menu item closes the dropdown
+- Dropdown anchored right so it doesn't overflow viewport
+
+### TotalsBar Changes (`src/client/components/TotalsBar.tsx`)
+
+- When `isAuthenticated`: render ` ` in place of the current "Sign out" button
+- When not authenticated: keep the existing "Sign in" link unchanged
+- Remove the `useLogout` hook usage from TotalsBar (moved into UserMenu)
+
+## No Backend Changes
+
+The existing `/api/auth/me` endpoint and `useAuth` hook are sufficient. No username display needed — using a generic user icon.
diff --git a/src/client/components/TotalsBar.tsx b/src/client/components/TotalsBar.tsx
index 1ad86d3..40e6aae 100644
--- a/src/client/components/TotalsBar.tsx
+++ b/src/client/components/TotalsBar.tsx
@@ -1,10 +1,11 @@
import { Link } from "@tanstack/react-router";
-import { useAuth, useLogout } from "../hooks/useAuth";
+import { useAuth } from "../hooks/useAuth";
import { useFormatters } from "../hooks/useFormatters";
import { useUpdateSetting } from "../hooks/useSettings";
import { useTotals } from "../hooks/useTotals";
import type { WeightUnit } from "../lib/formatters";
import { LucideIcon } from "../lib/iconData";
+import { UserMenu } from "./UserMenu";
const UNITS: WeightUnit[] = ["g", "oz", "lb", "kg"];
@@ -21,7 +22,6 @@ export function TotalsBar({
}: TotalsBarProps) {
const { data } = useTotals();
const { data: auth } = useAuth();
- const logout = useLogout();
const isAuthenticated = !!auth?.user;
const { weight, price, unit } = useFormatters();
const updateSetting = useUpdateSetting();
@@ -104,24 +104,16 @@ export function TotalsBar({
))}
)}
-
- {isAuthenticated ? (
- logout.mutate()}
- className="text-xs text-gray-500 hover:text-gray-700 transition-colors"
- >
- Sign out
-
- ) : (
-
- Sign in
-
- )}
-
+ {isAuthenticated ? (
+
+ ) : (
+
+ Sign in
+
+ )}
diff --git a/src/client/components/UserMenu.tsx b/src/client/components/UserMenu.tsx
new file mode 100644
index 0000000..4704650
--- /dev/null
+++ b/src/client/components/UserMenu.tsx
@@ -0,0 +1,57 @@
+import { Link } from "@tanstack/react-router";
+import { useEffect, useRef, useState } from "react";
+import { useLogout } from "../hooks/useAuth";
+import { LucideIcon } from "../lib/iconData";
+
+export function UserMenu() {
+ const [open, setOpen] = useState(false);
+ const menuRef = useRef(null);
+ const logout = useLogout();
+
+ useEffect(() => {
+ if (!open) return;
+ function handleClick(e: MouseEvent) {
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
+ setOpen(false);
+ }
+ }
+ document.addEventListener("mousedown", handleClick);
+ return () => document.removeEventListener("mousedown", handleClick);
+ }, [open]);
+
+ return (
+
+
setOpen((prev) => !prev)}
+ className="flex items-center justify-center w-8 h-8 rounded-full text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-colors"
+ >
+
+
+ {open && (
+
+
setOpen(false)}
+ className="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
+ >
+
+ Settings
+
+
+
{
+ setOpen(false);
+ logout.mutate();
+ }}
+ className="flex items-center gap-2 w-full px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
+ >
+
+ Sign out
+
+
+ )}
+
+ );
+}
diff --git a/src/server/mcp/tools/categories.ts b/src/server/mcp/tools/categories.ts
index 1ee2338..540b575 100644
--- a/src/server/mcp/tools/categories.ts
+++ b/src/server/mcp/tools/categories.ts
@@ -1,3 +1,4 @@
+import { z } from "zod";
import type { db as prodDb } from "../../../db/index.ts";
import {
createCategory,
@@ -24,24 +25,14 @@ export const categoryToolDefinitions = [
{
name: "list_categories",
description: "List all gear categories.",
- inputSchema: {
- type: "object" as const,
- properties: {},
- },
+ inputSchema: {},
},
{
name: "create_category",
description: "Create a new gear category.",
inputSchema: {
- type: "object" as const,
- properties: {
- name: { type: "string", description: "Category name" },
- icon: {
- type: "string",
- description: "Icon name (defaults to 'package')",
- },
- },
- required: ["name"],
+ name: z.string().describe("Category name"),
+ icon: z.string().optional().describe("Icon name (defaults to 'package')"),
},
},
];
diff --git a/src/server/mcp/tools/images.ts b/src/server/mcp/tools/images.ts
index aac55e1..5f52f36 100644
--- a/src/server/mcp/tools/images.ts
+++ b/src/server/mcp/tools/images.ts
@@ -1,3 +1,4 @@
+import { z } from "zod";
import { fetchImageFromUrl } from "../../services/image.service.ts";
interface ToolResult {
@@ -20,14 +21,9 @@ export const imageToolDefinitions = [
description:
"Fetch an image from a URL and save it locally. Returns the filename to use with create_item or add_candidate.",
inputSchema: {
- type: "object" as const,
- properties: {
- url: {
- type: "string",
- description: "URL of the image to fetch (jpeg, png, or webp)",
- },
- },
- required: ["url"],
+ url: z
+ .string()
+ .describe("URL of the image to fetch (jpeg, png, or webp)"),
},
},
];
diff --git a/src/server/mcp/tools/items.ts b/src/server/mcp/tools/items.ts
index 63ee111..b4d454d 100644
--- a/src/server/mcp/tools/items.ts
+++ b/src/server/mcp/tools/items.ts
@@ -1,3 +1,4 @@
+import { z } from "zod";
import type { db as prodDb } from "../../../db/index.ts";
import {
createItem,
@@ -29,24 +30,14 @@ export const itemToolDefinitions = [
description:
"List all items in the gear collection, optionally filtered by category.",
inputSchema: {
- type: "object" as const,
- properties: {
- categoryId: {
- type: "number",
- description: "Filter items by category ID",
- },
- },
+ categoryId: z.number().optional().describe("Filter items by category ID"),
},
},
{
name: "get_item",
description: "Get a single item by its ID, including all details.",
inputSchema: {
- type: "object" as const,
- properties: {
- id: { type: "number", description: "The item ID" },
- },
- required: ["id"],
+ id: z.number().describe("The item ID"),
},
},
{
@@ -54,60 +45,48 @@ export const itemToolDefinitions = [
description:
"Add a new item to the gear collection. Use this for items you've already decided on. For items you're still researching, use create_thread instead.",
inputSchema: {
- type: "object" as const,
- properties: {
- name: { type: "string", description: "Item name" },
- categoryId: { type: "number", description: "Category ID" },
- weightGrams: { type: "number", description: "Weight in grams" },
- priceCents: { type: "number", description: "Price in cents" },
- notes: { type: "string", description: "Notes about the item" },
- productUrl: { type: "string", description: "URL to the product page" },
- imageFilename: {
- type: "string",
- description: "Filename of an uploaded image",
- },
- imageSourceUrl: {
- type: "string",
- description: "Original URL the image was fetched from",
- },
- },
- required: ["name", "categoryId"],
+ name: z.string().describe("Item name"),
+ categoryId: z.number().describe("Category ID"),
+ weightGrams: z.number().optional().describe("Weight in grams"),
+ priceCents: z.number().optional().describe("Price in cents"),
+ notes: z.string().optional().describe("Notes about the item"),
+ productUrl: z.string().optional().describe("URL to the product page"),
+ imageFilename: z
+ .string()
+ .optional()
+ .describe("Filename of an uploaded image"),
+ imageSourceUrl: z
+ .string()
+ .optional()
+ .describe("Original URL the image was fetched from"),
},
},
{
name: "update_item",
description: "Update an existing item's fields.",
inputSchema: {
- type: "object" as const,
- properties: {
- id: { type: "number", description: "The item ID to update" },
- name: { type: "string", description: "Item name" },
- categoryId: { type: "number", description: "Category ID" },
- weightGrams: { type: "number", description: "Weight in grams" },
- priceCents: { type: "number", description: "Price in cents" },
- notes: { type: "string", description: "Notes about the item" },
- productUrl: { type: "string", description: "URL to the product page" },
- imageFilename: {
- type: "string",
- description: "Filename of an uploaded image",
- },
- imageSourceUrl: {
- type: "string",
- description: "Original URL the image was fetched from",
- },
- },
- required: ["id"],
+ id: z.number().describe("The item ID to update"),
+ name: z.string().optional().describe("Item name"),
+ categoryId: z.number().optional().describe("Category ID"),
+ weightGrams: z.number().optional().describe("Weight in grams"),
+ priceCents: z.number().optional().describe("Price in cents"),
+ notes: z.string().optional().describe("Notes about the item"),
+ productUrl: z.string().optional().describe("URL to the product page"),
+ imageFilename: z
+ .string()
+ .optional()
+ .describe("Filename of an uploaded image"),
+ imageSourceUrl: z
+ .string()
+ .optional()
+ .describe("Original URL the image was fetched from"),
},
},
{
name: "delete_item",
description: "Delete an item from the gear collection by ID.",
inputSchema: {
- type: "object" as const,
- properties: {
- id: { type: "number", description: "The item ID to delete" },
- },
- required: ["id"],
+ id: z.number().describe("The item ID to delete"),
},
},
];
diff --git a/src/server/mcp/tools/setups.ts b/src/server/mcp/tools/setups.ts
index 65b7c95..ab3b9ac 100644
--- a/src/server/mcp/tools/setups.ts
+++ b/src/server/mcp/tools/setups.ts
@@ -1,3 +1,4 @@
+import { z } from "zod";
import type { db as prodDb } from "../../../db/index.ts";
import {
createSetup,
@@ -28,31 +29,20 @@ export const setupToolDefinitions = [
name: "list_setups",
description:
"List all gear setups with item counts and weight/cost totals.",
- inputSchema: {
- type: "object" as const,
- properties: {},
- },
+ inputSchema: {},
},
{
name: "get_setup",
description: "Get a setup with all its items and details.",
inputSchema: {
- type: "object" as const,
- properties: {
- id: { type: "number", description: "Setup ID" },
- },
- required: ["id"],
+ id: z.number().describe("Setup ID"),
},
},
{
name: "create_setup",
description: "Create a new gear setup (e.g. 'Bikepacking weekend').",
inputSchema: {
- type: "object" as const,
- properties: {
- name: { type: "string", description: "Setup name" },
- },
- required: ["name"],
+ name: z.string().describe("Setup name"),
},
},
{
@@ -60,17 +50,12 @@ export const setupToolDefinitions = [
description:
"Update a setup's name and/or replace its item list. Pass itemIds to set exactly which items belong to this setup.",
inputSchema: {
- type: "object" as const,
- properties: {
- id: { type: "number", description: "Setup ID" },
- name: { type: "string", description: "New setup name" },
- itemIds: {
- type: "array",
- items: { type: "number" },
- description: "Array of item IDs to include in the setup",
- },
- },
- required: ["id"],
+ id: z.number().describe("Setup ID"),
+ name: z.string().optional().describe("New setup name"),
+ itemIds: z
+ .array(z.number())
+ .optional()
+ .describe("Array of item IDs to include in the setup"),
},
},
];
diff --git a/src/server/mcp/tools/threads.ts b/src/server/mcp/tools/threads.ts
index e6657e5..7c55606 100644
--- a/src/server/mcp/tools/threads.ts
+++ b/src/server/mcp/tools/threads.ts
@@ -1,3 +1,4 @@
+import { z } from "zod";
import type { db as prodDb } from "../../../db/index.ts";
import {
createCandidate,
@@ -31,14 +32,12 @@ export const threadToolDefinitions = [
description:
"List research threads. Threads are the recommended way to evaluate gear purchases — each thread tracks multiple candidates for a single gear slot, making it easy to compare options before committing.",
inputSchema: {
- type: "object" as const,
- properties: {
- includeResolved: {
- type: "boolean",
- description:
- "Include resolved threads (default: false, only active threads)",
- },
- },
+ includeResolved: z
+ .boolean()
+ .optional()
+ .describe(
+ "Include resolved threads (default: false, only active threads)",
+ ),
},
},
{
@@ -46,11 +45,7 @@ export const threadToolDefinitions = [
description:
"Get a thread with all its candidates for detailed comparison.",
inputSchema: {
- type: "object" as const,
- properties: {
- id: { type: "number", description: "Thread ID" },
- },
- required: ["id"],
+ id: z.number().describe("Thread ID"),
},
},
{
@@ -58,15 +53,8 @@ export const threadToolDefinitions = [
description:
"Start a new research thread for a gear slot. This is the preferred workflow: create a thread, add candidates with pros/cons/prices, compare them, then resolve the thread to add the winner to your collection.",
inputSchema: {
- type: "object" as const,
- properties: {
- name: {
- type: "string",
- description: "Thread name (e.g. 'Handlebar bag')",
- },
- categoryId: { type: "number", description: "Category ID" },
- },
- required: ["name", "categoryId"],
+ name: z.string().describe("Thread name (e.g. 'Handlebar bag')"),
+ categoryId: z.number().describe("Category ID"),
},
},
{
@@ -74,35 +62,24 @@ export const threadToolDefinitions = [
description:
"Resolve a research thread by picking the winning candidate. The winner is automatically added to the gear collection as a new item, and the thread is marked as resolved.",
inputSchema: {
- type: "object" as const,
- properties: {
- threadId: { type: "number", description: "Thread ID" },
- candidateId: {
- type: "number",
- description: "ID of the winning candidate",
- },
- },
- required: ["threadId", "candidateId"],
+ threadId: z.number().describe("Thread ID"),
+ candidateId: z.number().describe("ID of the winning candidate"),
},
},
{
name: "add_candidate",
description: "Add a candidate option to a research thread for comparison.",
inputSchema: {
- type: "object" as const,
- properties: {
- threadId: { type: "number", description: "Thread ID" },
- name: { type: "string", description: "Candidate name" },
- categoryId: { type: "number", description: "Category ID" },
- weightGrams: { type: "number", description: "Weight in grams" },
- priceCents: { type: "number", description: "Price in cents" },
- notes: { type: "string", description: "Notes" },
- productUrl: { type: "string", description: "Product URL" },
- imageFilename: { type: "string", description: "Image filename" },
- pros: { type: "string", description: "Pros of this candidate" },
- cons: { type: "string", description: "Cons of this candidate" },
- },
- required: ["threadId", "name", "categoryId"],
+ threadId: z.number().describe("Thread ID"),
+ name: z.string().describe("Candidate name"),
+ categoryId: z.number().describe("Category ID"),
+ weightGrams: z.number().optional().describe("Weight in grams"),
+ priceCents: z.number().optional().describe("Price in cents"),
+ notes: z.string().optional().describe("Notes"),
+ productUrl: z.string().optional().describe("Product URL"),
+ imageFilename: z.string().optional().describe("Image filename"),
+ pros: z.string().optional().describe("Pros of this candidate"),
+ cons: z.string().optional().describe("Cons of this candidate"),
},
},
{
@@ -110,36 +87,28 @@ export const threadToolDefinitions = [
description:
"Update a candidate's details (name, price, pros, cons, etc.).",
inputSchema: {
- type: "object" as const,
- properties: {
- id: { type: "number", description: "Candidate ID" },
- name: { type: "string", description: "Candidate name" },
- weightGrams: { type: "number", description: "Weight in grams" },
- priceCents: { type: "number", description: "Price in cents" },
- categoryId: { type: "number", description: "Category ID" },
- notes: { type: "string", description: "Notes" },
- productUrl: { type: "string", description: "Product URL" },
- imageFilename: { type: "string", description: "Image filename" },
- imageSourceUrl: { type: "string", description: "Image source URL" },
- status: {
- type: "string",
- description: "Status: researching, ordered, or arrived",
- },
- pros: { type: "string", description: "Pros" },
- cons: { type: "string", description: "Cons" },
- },
- required: ["id"],
+ id: z.number().describe("Candidate ID"),
+ name: z.string().optional().describe("Candidate name"),
+ weightGrams: z.number().optional().describe("Weight in grams"),
+ priceCents: z.number().optional().describe("Price in cents"),
+ categoryId: z.number().optional().describe("Category ID"),
+ notes: z.string().optional().describe("Notes"),
+ productUrl: z.string().optional().describe("Product URL"),
+ imageFilename: z.string().optional().describe("Image filename"),
+ imageSourceUrl: z.string().optional().describe("Image source URL"),
+ status: z
+ .string()
+ .optional()
+ .describe("Status: researching, ordered, or arrived"),
+ pros: z.string().optional().describe("Pros"),
+ cons: z.string().optional().describe("Cons"),
},
},
{
name: "remove_candidate",
description: "Remove a candidate from a research thread.",
inputSchema: {
- type: "object" as const,
- properties: {
- id: { type: "number", description: "Candidate ID to remove" },
- },
- required: ["id"],
+ id: z.number().describe("Candidate ID to remove"),
},
},
];