diff --git a/app/globals.css b/app/globals.css index 0e65dd7..36bd327 100644 --- a/app/globals.css +++ b/app/globals.css @@ -56,6 +56,29 @@ --thesis-gradient: linear-gradient(135deg, oklch(0.7 0.15 160), oklch(0.7 0.15 250)); --thesis-foreground: oklch(0.15 0.01 260); --thesis-accent: oklch(0.7 0.15 200); + --ui-brand: #3ecf6e; + --ui-surface-canvas: #020202; + --ui-surface-modal: #0d0d0d; + --ui-surface-panel: #0d0d10; + --ui-warning-bg: rgba(69, 26, 3, 0.8); + --ui-warning-border: rgba(146, 64, 14, 0.6); + --ui-warning-text: #fde68a; + --ui-warning-button-bg: rgba(180, 83, 9, 0.6); + --ui-warning-button-hover: rgba(217, 119, 6, 0.7); + --ui-warning-button-text: #fef3c7; + --ui-warning-button-border: rgba(217, 119, 6, 0.5); + --ui-danger-bg: rgba(239, 68, 68, 0.1); + --ui-danger-border: rgba(239, 68, 68, 0.2); + --ui-danger-text: rgba(248, 113, 113, 0.8); + --ui-danger-strong: #fca5a5; + --ui-danger-icon: #f87171; + --ui-danger-hover-bg: rgba(239, 68, 68, 0.2); + --ui-icon-task: #818cf8; + --ui-icon-thesis: #facc15; + --ui-icon-question: #60a5fa; + --ui-og-bg: #0a0a0a; + --ui-og-text: #f0f0f0; + --ui-og-muted: #666666; } .dark { @@ -91,6 +114,29 @@ --sidebar-accent-foreground: oklch(0.85 0 0); --sidebar-border: oklch(0.28 0.01 260); --sidebar-ring: oklch(0.72 0.19 155); + --ui-brand: #3ecf6e; + --ui-surface-canvas: #020202; + --ui-surface-modal: #0d0d0d; + --ui-surface-panel: #0d0d10; + --ui-warning-bg: rgba(69, 26, 3, 0.8); + --ui-warning-border: rgba(146, 64, 14, 0.6); + --ui-warning-text: #fde68a; + --ui-warning-button-bg: rgba(180, 83, 9, 0.6); + --ui-warning-button-hover: rgba(217, 119, 6, 0.7); + --ui-warning-button-text: #fef3c7; + --ui-warning-button-border: rgba(217, 119, 6, 0.5); + --ui-danger-bg: rgba(239, 68, 68, 0.1); + --ui-danger-border: rgba(239, 68, 68, 0.2); + --ui-danger-text: rgba(248, 113, 113, 0.8); + --ui-danger-strong: #fca5a5; + --ui-danger-icon: #f87171; + --ui-danger-hover-bg: rgba(239, 68, 68, 0.2); + --ui-icon-task: #818cf8; + --ui-icon-thesis: #facc15; + --ui-icon-question: #60a5fa; + --ui-og-bg: #0a0a0a; + --ui-og-text: #f0f0f0; + --ui-og-muted: #666666; } @theme inline { @@ -102,6 +148,27 @@ --color-card-foreground: var(--card-foreground); --color-popover: var(--popover); --color-popover-foreground: var(--popover-foreground); + --color-ui-surface-canvas: var(--ui-surface-canvas); + --color-ui-surface-modal: var(--ui-surface-modal); + --color-ui-surface-panel: var(--ui-surface-panel); + --color-ui-brand: var(--ui-brand); + --color-ui-warning-bg: var(--ui-warning-bg); + --color-ui-warning-border: var(--ui-warning-border); + --color-ui-warning-text: var(--ui-warning-text); + --color-ui-warning-button-bg: var(--ui-warning-button-bg); + --color-ui-warning-button-hover: var(--ui-warning-button-hover); + --color-ui-warning-button-text: var(--ui-warning-button-text); + --color-ui-warning-button-border: var(--ui-warning-button-border); + --color-ui-danger-bg: var(--ui-danger-bg); + --color-ui-danger-border: var(--ui-danger-border); + --color-ui-danger-text: var(--ui-danger-text); + --color-ui-danger-strong: var(--ui-danger-strong); + --color-ui-danger-icon: var(--ui-danger-icon); + --color-ui-danger-hover-bg: var(--ui-danger-hover-bg); + --color-ui-icon-task: var(--ui-icon-task); + --color-ui-icon-thesis: var(--ui-icon-thesis); + --color-ui-icon-question: var(--ui-icon-question); + --color-sidebar: var(--sidebar); --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); --color-secondary: var(--secondary); diff --git a/app/not-found.tsx b/app/not-found.tsx index 0b38538..08ef281 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -53,9 +53,9 @@ export default function NotFound() { {/* Logo mark */}
- - - + + +
nodepad diff --git a/app/opengraph-image.tsx b/app/opengraph-image.tsx index ef87b4c..bc67e9d 100644 --- a/app/opengraph-image.tsx +++ b/app/opengraph-image.tsx @@ -5,6 +5,13 @@ export const alt = "nodepad — spatial AI research tool" export const size = { width: 1200, height: 630 } export const contentType = "image/png" +const OG_COLORS = { + background: "#0a0a0a", + text: "#f0f0f0", + muted: "#666666", + brand: "#3ecf6e", +} + export default function OGImage() { return new ImageResponse( ( @@ -16,7 +23,7 @@ export default function OGImage() { flexDirection: "column", alignItems: "flex-start", justifyContent: "flex-end", - background: "#0a0a0a", + background: OG_COLORS.background, padding: "80px 96px", fontFamily: "sans-serif", }} @@ -24,11 +31,11 @@ export default function OGImage() { {/* Logo mark */}
-
-
-
+
+
+
- + nodepad
@@ -38,7 +45,7 @@ export default function OGImage() { style={{ fontSize: 72, fontWeight: 700, - color: "#f0f0f0", + color: OG_COLORS.text, lineHeight: 1.05, letterSpacing: "-2px", marginBottom: 32, @@ -46,11 +53,11 @@ export default function OGImage() { > Think spatially.
- Let AI fill the gaps. + Let AI fill the gaps.
{/* Subline */} -
+
nodepad.space
diff --git a/app/page.tsx b/app/page.tsx index 948b1fe..9a76ca0 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -24,6 +24,7 @@ import { downloadNodepadFile, parseNodepadFile, NodepadParseError } from "@/lib/ import { detectContentType } from "@/lib/detect-content-type" import { clearSession, getSessionUser, type SessionUser } from "@/lib/auth" import { fetchUserState, saveUserState } from "@/lib/user-state" +import { getGlobalKeybindAction } from "@/lib/keybinds" const SKIP_LOGIN_KEY = "nodepad-skip-login" const GUEST_PROJECTS_KEY = "nodepad-guest-projects" @@ -683,11 +684,16 @@ export default function Page() { useEffect(() => { const handleKeys = (e: KeyboardEvent) => { - if (e.key === "k" && (e.metaKey || e.ctrlKey)) { + const action = getGlobalKeybindAction(e) + if (action === "command-menu") { e.preventDefault() setIsCommandKOpen(prev => !prev) } - if (e.key === "z" && (e.metaKey || e.ctrlKey) && !e.shiftKey) { + if (action === "toggle-sidebar") { + e.preventDefault() + setIsSidebarOpen(prev => !prev) + } + if (action === "undo") { // Don't intercept while typing in an input/textarea const tag = (e.target as HTMLElement).tagName if (tag !== "INPUT" && tag !== "TEXTAREA") { @@ -695,7 +701,7 @@ export default function Page() { undo() } } - if (e.key === "Escape") { + if (action === "escape") { if (isCommandKOpen) { setIsCommandKOpen(false) } else if (isGhostPanelOpen) { @@ -1103,12 +1109,12 @@ export default function Page() { /> {isHydrated && !settings.apiKey && ( -
- ⚡ AI enrichment requires an OpenRouter API key — use a free model (no credits needed) or add credits for GPT-4o, Claude, and more. Configure in the ☰ left panel. +
+ ⚡ AI enrichment requires an OpenRouter API key — use a free model (no credits needed) or add credits for GPT-4o, Claude, and more. Configure in the ☰ left panel.
@@ -1204,7 +1210,7 @@ export default function Page() { animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 4 }} transition={{ duration: 0.15, ease: "easeOut" }} - className="absolute bottom-[72px] left-1/2 -translate-x-1/2 z-[130] pointer-events-none" + className="absolute bottom-18 left-1/2 -translate-x-1/2 z-130 pointer-events-none" >
{undoToast} diff --git a/components/about-panel.tsx b/components/about-panel.tsx index 87db0b8..8e3e05d 100644 --- a/components/about-panel.tsx +++ b/components/about-panel.tsx @@ -8,6 +8,7 @@ import { FolderInput, Download, Brain, Zap, Globe, Search, Check, Mail } from "lucide-react" import { useModKey } from "@/lib/utils" +import { getKeyboardShortcuts } from "@/lib/keybinds" interface AboutPanelProps { open: boolean @@ -64,7 +65,7 @@ function Section({ title, children }: { title: string; children: React.ReactNode function Step({ n, title, children }: { n: number; title: string; children: React.ReactNode }) { return (
-
+
{n}
@@ -96,16 +97,17 @@ const CONTENT_TYPE_HIGHLIGHTS = [ export function AboutPanel({ open, onClose }: AboutPanelProps) { const mod = useModKey() + const shortcuts = getKeyboardShortcuts(mod) return ( { if (!v) onClose() }}> About nodepad {/* Header */} -
+
@@ -205,7 +207,7 @@ export function AboutPanel({ open, onClose }: AboutPanelProps) { const Icon = config.icon return (
- +

{config.label} @@ -224,21 +226,21 @@ export function AboutPanel({ open, onClose }: AboutPanelProps) {

- +

Tiling {mod}1

Default. Nodes are laid out in a Binary Space Partition grid — each new node splits the available space. Navigate pages horizontally. A minimap in the bottom-right shows your spatial position.

- +

Kanban {mod}2

Nodes grouped into columns by content type. Good for reviewing your thinking by category. Tasks always appear first.

- +

Graph {mod}3

An interactive force-directed graph of all your nodes. Connections between them become the focus — highly-connected nodes drift toward the centre, isolated ones settle at the periphery. Click any node to open its full detail panel. Hover to dim unrelated nodes.

@@ -258,7 +260,7 @@ export function AboutPanel({ open, onClose }: AboutPanelProps) { { icon: Sparkles, title: "Synthesis", desc: "After ≥3 nodes, nodepad quietly generates an emergent thesis — a 15–25 word synthesis of what you're actually thinking about. Solidify it to keep it, or dismiss." }, ].map(({ icon: Icon, title, desc }) => (
- +

{title}

{desc}

@@ -272,21 +274,21 @@ export function AboutPanel({ open, onClose }: AboutPanelProps) {
- +

Export .nodepad

Save your full research space as a .nodepad file. Import it on any device to pick up where you left off.

- +

Export Markdown

Export a richly formatted Markdown document with YAML front matter, a table of contents, grouped sections, confidence tables for claims, and cited sources.

- +

Your data, synced

Your projects and notes are stored in your account on the server (PostgreSQL) and synced across sessions. Notes are still sent to the AI provider of your choice (OpenRouter, OpenAI, or Z.ai) using your own API key.

@@ -299,10 +301,9 @@ export function AboutPanel({ open, onClose }: AboutPanelProps) {
- - - - + {shortcuts.map((shortcut) => ( + + ))}
@@ -320,7 +321,7 @@ export function AboutPanel({ open, onClose }: AboutPanelProps) { "Use multiple projects (sidebar) to keep separate research threads isolated.", ].map((tip, i) => (
  • - + {tip}
  • ))} diff --git a/components/ghost-panel.tsx b/components/ghost-panel.tsx index d7dd107..7fcc8ec 100644 --- a/components/ghost-panel.tsx +++ b/components/ghost-panel.tsx @@ -28,7 +28,7 @@ export function GhostPanel({ ghostNotes, isOpen, onClose, onClaim, onDismiss }: }} className="flex flex-col h-full bg-black/20 backdrop-blur-3xl border-l border-border shrink-0 overflow-hidden relative z-50 transition-all duration-200 ease-in-out" > -
    +
    {/* Header */}
    diff --git a/components/graph-area.tsx b/components/graph-area.tsx index 375ed37..8e73805 100644 --- a/components/graph-area.tsx +++ b/components/graph-area.tsx @@ -399,7 +399,7 @@ export function GraphArea({ > {blocks.length === 0 && (
    -
    +

    force-directed graph view

    @@ -706,14 +706,14 @@ export function GraphArea({ >
    {config?.icon && React.createElement(config.icon, { - className: "h-3 w-3 flex-shrink-0", + className: "h-3 w-3 shrink-0", style: { color: "black", opacity: 0.7 }, })} {node.isSynthesis ? "Synthesis" : config?.label} {node.block?.category && ( - + {node.block.category} )} diff --git a/components/graph-detail-panel.tsx b/components/graph-detail-panel.tsx index 1052114..00a264a 100644 --- a/components/graph-detail-panel.tsx +++ b/components/graph-detail-panel.tsx @@ -182,12 +182,12 @@ export function GraphDetailPanel({ {/* ── Header bar — matches tile-card style ───────────────────────── */}
    {/* Type display — read-only label; shimmer while enriching */} - + {config.label} @@ -196,7 +196,7 @@ export function GraphDetailPanel({ #{block.category || "no-topic"}
    -
    +
    {date} {/* Change-type button — portal dropdown, clear of panel overflow:hidden */} @@ -588,14 +641,14 @@ export function ProjectSidebar({
    )}
    + +
    { + if (!isOpen) return + event.preventDefault() + setIsResizing(true) + resizeStartXRef.current = event.clientX + resizeStartWidthRef.current = sidebarWidth + }} + className={`absolute right-0 top-0 h-full w-1 cursor-col-resize transition-colors ${isResizing ? "bg-primary/30" : "bg-transparent hover:bg-white/10"}`} + />
    ) diff --git a/components/status-bar.tsx b/components/status-bar.tsx index f70c6df..226a479 100644 --- a/components/status-bar.tsx +++ b/components/status-bar.tsx @@ -235,7 +235,7 @@ export function StatusBar({ animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: -4, scale: 0.96 }} transition={{ duration: 0.2, ease: "easeOut" }} - className="absolute right-0 top-full mt-2.5 z-[300] w-48 rounded-sm bg-primary text-primary-foreground shadow-lg pointer-events-none select-none" + className="absolute right-0 top-full mt-2.5 z-300 w-48 rounded-sm bg-primary text-primary-foreground shadow-lg pointer-events-none select-none" > {/* Arrow pointing up toward the ? button */}
    diff --git a/components/tile-card.tsx b/components/tile-card.tsx index ae131a8..6c5d596 100644 --- a/components/tile-card.tsx +++ b/components/tile-card.tsx @@ -341,7 +341,7 @@ export const TileCard = memo(function TileCard({ {/* Header */}
    {effectiveCollapsed ? ( @@ -374,8 +374,8 @@ export const TileCard = memo(function TileCard({ )} {/* Type display — read-only label */} - - + + {config.label} @@ -385,7 +385,7 @@ export const TileCard = memo(function TileCard({ )}
    -
    +
    {block.influencedBy && block.influencedBy.length > 0 && ( )} @@ -431,7 +431,7 @@ export const TileCard = memo(function TileCard({ e.stopPropagation() onTogglePin(block.id) }} - className={`flex h-4 w-4 items-center justify-center rounded-sm transition-all shadow-sm ${block.isPinned ? "bg-black/20 opacity-100 scale-110 !opacity-100" : "opacity-40 hover:opacity-100 hover:bg-black/10"}`} + className={`flex h-4 w-4 items-center justify-center rounded-sm transition-all shadow-sm ${block.isPinned ? "bg-black/20 opacity-100 scale-110" : "opacity-40 hover:opacity-100 hover:bg-black/10"}`} aria-label={block.isPinned ? "Unpin note" : "Pin note"} title={block.isPinned ? "Unpin note" : "Pin note"} > @@ -518,7 +518,7 @@ export const TileCard = memo(function TileCard({ }} className={`flex items-center gap-2 rounded-sm px-2 py-1.5 text-left transition-all hover:bg-secondary/60 ${isActive ? "bg-secondary/80" : ""}`} > - + Move or copy to space

    -
    +
    {workspaces.filter(w => w.id !== activeWorkspaceId).map(w => (
    {block.isError && ( -
    - +
    + {block.statusText === "no-api-key" - ? <>AI enrichment failed — no API key. Open the ☰ sidebar → Settings to add your API key. + ? <>AI enrichment failed — no API key. Open the ☰ sidebar → Settings to add your API key. : block.statusText - ? <>{block.statusText}{" "}Double-click to retry. - : "Enrichment failed. Double-click to retry."} + ? <>{block.statusText} + : "Enrichment failed."} +
    )}
    @@ -668,9 +675,9 @@ export const TileCard = memo(function TileCard({ onDeleteSubTask?.(block.id, st.id) } }} - className="opacity-0 group-hover/task:opacity-100 p-1 hover:bg-red-500/20 rounded transition-all" + className="opacity-0 group-hover/task:opacity-100 p-1 hover:bg-(--ui-danger-hover-bg) rounded transition-all" > - +
    ))} @@ -722,7 +729,7 @@ export const TileCard = memo(function TileCard({ {/* Confidence bar */} {block.confidence !== undefined && block.confidence !== null && !isEditing && ( -
    +
    Confidence {Math.round(block.confidence)}% @@ -744,7 +751,7 @@ export const TileCard = memo(function TileCard({ {/* Footer */}
    # - {block.category || "no-topic"} + {block.category || "no-topic"} {block.influencedBy && block.influencedBy.length > 0 && ( @@ -781,7 +788,7 @@ export const TileCard = memo(function TileCard({
    {/* Hover Tooltip */} -
    +
    Connected nodes
    {block.influencedBy.slice(0, 5).map((id, i) => { @@ -811,12 +818,12 @@ export const TileCard = memo(function TileCard({ onClick={() => setIsFooterExpanded(!isFooterExpanded)} className={`rounded-sm p-1 transition-all ${isFooterExpanded ? 'bg-primary/20 text-primary' : 'text-muted-foreground/40 hover:text-muted-foreground/60'}`} > - {isFooterExpanded ? : } + {isFooterExpanded ? : } )}
    - Node ID: + Node ID: #{block.id.slice(0, 6)}
    @@ -896,7 +903,7 @@ function renderBody( return (
    {linkifyText(text)}

    -
    +
    ) default: diff --git a/components/tile-index.tsx b/components/tile-index.tsx index bee334d..f5132dc 100644 --- a/components/tile-index.tsx +++ b/components/tile-index.tsx @@ -17,9 +17,9 @@ interface TileIndexProps { export function TileIndex({ blocks, onHighlight, highlightedId, onClose, isOpen, viewMode }: TileIndexProps) { const getIcon = (type: string) => { switch (type) { - case "task": return - case "thesis": return - case "question": return + case "task": return + case "thesis": return + case "question": return default: return } } @@ -74,7 +74,7 @@ export function TileIndex({ blocks, onHighlight, highlightedId, onClose, isOpen, }} className="flex flex-col h-full bg-black/20 backdrop-blur-3xl border-l border-border shrink-0 overflow-hidden relative z-50 transition-all duration-200 ease-in-out" > -
    +
    {/* Header */}
    {onClose && ( diff --git a/components/tiling-area.tsx b/components/tiling-area.tsx index fb352e5..0b3c152 100644 --- a/components/tiling-area.tsx +++ b/components/tiling-area.tsx @@ -242,7 +242,7 @@ export function TilingArea({ } return ( -
    +
    {/* Task Header stays sticky at top */} {taskBlock && (
    @@ -304,7 +304,7 @@ export function TilingArea({ {/* Empty state — absolutely positioned so it centers identically across all views */} {pageTrees.length === 0 && !taskBlock && (
    -
    +

    spatial research workspace

    diff --git a/components/tiling-minimap.tsx b/components/tiling-minimap.tsx index 3e135b4..bf1b654 100644 --- a/components/tiling-minimap.tsx +++ b/components/tiling-minimap.tsx @@ -49,7 +49,7 @@ export function TilingMinimap({ pages, activePageIdx, onPageClick }: TilingMinim return (
    @@ -68,20 +68,20 @@ export function TilingMinimap({ pages, activePageIdx, onPageClick }: TilingMinim onClick={() => onPageClick(idx)} onMouseEnter={() => setHoveredIdx(idx)} onMouseLeave={() => setHoveredIdx(null)} - className={`group relative flex flex-col items-center gap-[4px] p-1.5 rounded-md transition-all duration-150 outline-none ${ + className={`group relative flex flex-col items-center gap-1 p-1.5 rounded-md transition-all duration-150 outline-none ${ isActive ? "bg-primary/15 border border-primary/40 shadow-[0_0_0_1px_var(--primary)]" - : "border border-white/10 bg-white/[0.04] hover:bg-white/[0.09] hover:border-white/25" + : "border border-white/10 bg-white/4 hover:bg-white/9 hover:border-white/25" }`} > {/* Dot grid — up to 3 columns, rows as needed */} -
    +
    {page.map(block => { const config = CONTENT_TYPE_CONFIG[block.contentType] return (
    +
    { @@ -272,7 +272,7 @@ export function VimInput({ onSubmit, onCommand, isCommandKOpen, setIsCommandKOpe )}
    -
    +
    {/* ── Views ──────────────────────────────────────────────── */} {viewItems.length > 0 && ( @@ -287,9 +287,9 @@ export function VimInput({ onSubmit, onCommand, isCommandKOpen, setIsCommandKOpe ref={el => { itemRefs.current[i] = el }} onClick={() => handleSelect(item.id)} onMouseEnter={() => setFocusedIdx(i)} - className={`group flex flex-col items-center justify-center gap-2 rounded-sm border py-4 px-2 transition-all duration-100 outline-none ${focused ? "bg-primary/12 border-primary/35 text-primary shadow-[0_0_0_1px_var(--primary),inset_0_1px_0_rgba(255,255,255,0.05)]" : "bg-white/[0.03] border-white/[0.07] text-white/55 hover:bg-white/[0.06] hover:border-white/20 hover:text-white/80"}`} + className={`group flex flex-col items-center justify-center gap-2 rounded-sm border py-4 px-2 transition-all duration-100 outline-none ${focused ? "bg-primary/12 border-primary/35 text-primary shadow-[0_0_0_1px_var(--primary),inset_0_1px_0_rgba(255,255,255,0.05)]" : "bg-white/3 border-white/[0.07] text-white/55 hover:bg-white/6 hover:border-white/20 hover:text-white/80"}`} > - +
    {item.label}
    {item.sub &&
    {item.sub}
    } @@ -315,9 +315,9 @@ export function VimInput({ onSubmit, onCommand, isCommandKOpen, setIsCommandKOpe ref={el => { itemRefs.current[idx] = el }} onClick={() => handleSelect(item.id)} onMouseEnter={() => setFocusedIdx(idx)} - className={`group flex flex-col items-center justify-center gap-2 rounded-sm border py-4 px-2 transition-all duration-100 outline-none ${focused ? "bg-primary/12 border-primary/35 text-primary shadow-[0_0_0_1px_var(--primary),inset_0_1px_0_rgba(255,255,255,0.05)]" : "bg-white/[0.03] border-white/[0.07] text-white/55 hover:bg-white/[0.06] hover:border-white/20 hover:text-white/80"}`} + className={`group flex flex-col items-center justify-center gap-2 rounded-sm border py-4 px-2 transition-all duration-100 outline-none ${focused ? "bg-primary/12 border-primary/35 text-primary shadow-[0_0_0_1px_var(--primary),inset_0_1px_0_rgba(255,255,255,0.05)]" : "bg-white/3 border-white/[0.07] text-white/55 hover:bg-white/6 hover:border-white/20 hover:text-white/80"}`} > - +
    {item.label}
    {item.sub &&
    {item.sub}
    } @@ -343,9 +343,9 @@ export function VimInput({ onSubmit, onCommand, isCommandKOpen, setIsCommandKOpe ref={el => { itemRefs.current[idx] = el }} onClick={() => handleSelect(item.id)} onMouseEnter={() => setFocusedIdx(idx)} - className={`group flex flex-col items-center justify-center gap-2 rounded-sm border py-4 px-2 transition-all duration-100 outline-none ${focused ? "bg-primary/12 border-primary/35 text-primary shadow-[0_0_0_1px_var(--primary),inset_0_1px_0_rgba(255,255,255,0.05)]" : "bg-white/[0.03] border-white/[0.07] text-white/55 hover:bg-white/[0.06] hover:border-white/20 hover:text-white/80"}`} + className={`group flex flex-col items-center justify-center gap-2 rounded-sm border py-4 px-2 transition-all duration-100 outline-none ${focused ? "bg-primary/12 border-primary/35 text-primary shadow-[0_0_0_1px_var(--primary),inset_0_1px_0_rgba(255,255,255,0.05)]" : "bg-white/3 border-white/[0.07] text-white/55 hover:bg-white/6 hover:border-white/20 hover:text-white/80"}`} > - +
    {item.label}
    {item.sub}
    @@ -385,7 +385,7 @@ export function VimInput({ onSubmit, onCommand, isCommandKOpen, setIsCommandKOpe {/* ── Main Input Bar ─────────────────────────────────────────────── */}
    -
    +
    {showTagSuggestions && (
    diff --git a/lib/keybinds.tsx b/lib/keybinds.tsx new file mode 100644 index 0000000..f6eae15 --- /dev/null +++ b/lib/keybinds.tsx @@ -0,0 +1,49 @@ + +export interface KeybindDefinition { + id: string + keys: string[] + label: string +} + +export interface KeybindBinding { + id: string + key: string + requiresMod?: boolean + requiresShift?: boolean +} + +export const getKeyboardShortcuts = (modKey: string): KeybindDefinition[] => [ + { id: "command-menu", keys: [modKey, "K"], label: "Command menu" }, + { id: "toggle-sidebar", keys: [modKey, "B"], label: "Toggle sidebar" }, + { id: "undo", keys: [modKey, "Z"], label: "Undo last action" }, + { id: "submit-node", keys: ["Enter"], label: "Submit a new node" }, + { id: "escape", keys: ["Esc"], label: "Close command menu / deselect" }, +] + +export const GLOBAL_KEYBINDS: KeybindBinding[] = [ + { id: "command-menu", key: "k", requiresMod: true }, + { id: "toggle-sidebar", key: "b", requiresMod: true }, + { id: "undo", key: "z", requiresMod: true, requiresShift: false }, + { id: "escape", key: "Escape" }, +] + +export const matchKeybind = (event: KeyboardEvent, binding: KeybindBinding): boolean => { + const key = event.key.length === 1 ? event.key.toLowerCase() : event.key + const expectedKey = binding.key.length === 1 ? binding.key.toLowerCase() : binding.key + if (key !== expectedKey) return false + + if (binding.requiresMod) { + if (!event.metaKey && !event.ctrlKey) return false + } + + if (typeof binding.requiresShift === "boolean" && event.shiftKey !== binding.requiresShift) { + return false + } + + return true +} + +export const getGlobalKeybindAction = (event: KeyboardEvent): string | null => { + const match = GLOBAL_KEYBINDS.find(binding => matchKeybind(event, binding)) + return match?.id ?? null +}