feat(search): add real-time full text search with Command+K modal using Convex search indexes and Phosphor Icons

This commit is contained in:
Wayne Sutton
2025-12-17 22:02:52 -08:00
parent 97081dc82d
commit e5b22487ca
13 changed files with 924 additions and 27 deletions

View File

@@ -12,6 +12,7 @@ A minimalist markdown site built with React, Convex, and Vite. Optimized for SEO
- Real-time data with Convex
- Fully responsive design
- Real-time analytics at `/stats`
- Full text search with Command+K shortcut
### SEO and Discovery
@@ -263,8 +264,23 @@ markdown-site/
- react-syntax-highlighter
- date-fns
- lucide-react
- @phosphor-icons/react
- Netlify
## Search
Press `Command+K` (Mac) or `Ctrl+K` (Windows/Linux) to open the search modal. The search uses Convex full text search to find posts and pages by title and content.
Features:
- Real-time results as you type
- Keyboard navigation (arrow keys, Enter, Escape)
- Result snippets with context around matches
- Distinguishes between posts and pages
- Works with all four themes
The search icon appears in the top navigation bar next to the theme toggle.
## Real-time Stats
The `/stats` page shows real-time analytics powered by Convex:

View File

@@ -2,7 +2,7 @@
## Current Status
v1.2.0 ready for deployment. Build passes. TypeScript verified.
v1.3.0 ready for deployment. Build passes. TypeScript verified.
## Completed
@@ -33,6 +33,9 @@ v1.2.0 ready for deployment. Build passes. TypeScript verified.
- [x] Active session heartbeat system
- [x] Cron job for stale session cleanup
- [x] Stats link in homepage footer
- [x] Real-time search with Command+K shortcut
- [x] Search modal with keyboard navigation
- [x] Full text search indexes for posts and pages
## Deployment Steps
@@ -43,7 +46,6 @@ v1.2.0 ready for deployment. Build passes. TypeScript verified.
## Future Enhancements
- [ ] Search functionality
- [ ] Related posts suggestions
- [ ] Newsletter signup
- [ ] Comments system

View File

@@ -4,7 +4,28 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [1.2.0] - 2025-12-14
## [1.3.0] - 2025-12-17
### Added
- Real-time search with Command+K keyboard shortcut
- Search icon in top nav using Phosphor Icons
- Modal with keyboard navigation (arrow keys, Enter, Escape)
- Full text search across posts and pages using Convex search indexes
- Result snippets with context around search matches
- Distinguishes between posts and pages with type badges
- Search indexes for pages table (title and content)
- New `@phosphor-icons/react` dependency for search icon
### Technical
- Uses Convex full text search with reactive queries
- Deduplicates results from title and content searches
- Sorts results with title matches first
- Mobile responsive modal design
- All four themes supported (dark, light, tan, cloud)
## [1.2.0] - 2025-12-15
### Added

View File

@@ -13,6 +13,7 @@ import type * as http from "../http.js";
import type * as pages from "../pages.js";
import type * as posts from "../posts.js";
import type * as rss from "../rss.js";
import type * as search from "../search.js";
import type * as stats from "../stats.js";
import type {
@@ -27,6 +28,7 @@ declare const fullApi: ApiFromModules<{
pages: typeof pages;
posts: typeof posts;
rss: typeof rss;
search: typeof search;
stats: typeof stats;
}>;

View File

@@ -37,7 +37,15 @@ export default defineSchema({
lastSyncedAt: v.number(),
})
.index("by_slug", ["slug"])
.index("by_published", ["published"]),
.index("by_published", ["published"])
.searchIndex("search_content", {
searchField: "content",
filterFields: ["published"],
})
.searchIndex("search_title", {
searchField: "title",
filterFields: ["published"],
}),
// View counts for analytics
viewCounts: defineTable({

159
convex/search.ts Normal file
View File

@@ -0,0 +1,159 @@
import { query } from "./_generated/server";
import { v } from "convex/values";
// Search result type for both posts and pages
const searchResultValidator = v.object({
_id: v.string(),
type: v.union(v.literal("post"), v.literal("page")),
slug: v.string(),
title: v.string(),
description: v.optional(v.string()),
snippet: v.string(),
});
// Search across posts and pages
export const search = query({
args: {
query: v.string(),
},
returns: v.array(searchResultValidator),
handler: async (ctx, args) => {
// Return empty results for empty queries
if (!args.query.trim()) {
return [];
}
const results: Array<{
_id: string;
type: "post" | "page";
slug: string;
title: string;
description?: string;
snippet: string;
}> = [];
// Search posts by title
const postsByTitle = await ctx.db
.query("posts")
.withSearchIndex("search_title", (q) =>
q.search("title", args.query).eq("published", true)
)
.take(10);
// Search posts by content
const postsByContent = await ctx.db
.query("posts")
.withSearchIndex("search_content", (q) =>
q.search("content", args.query).eq("published", true)
)
.take(10);
// Search pages by title
const pagesByTitle = await ctx.db
.query("pages")
.withSearchIndex("search_title", (q) =>
q.search("title", args.query).eq("published", true)
)
.take(10);
// Search pages by content
const pagesByContent = await ctx.db
.query("pages")
.withSearchIndex("search_content", (q) =>
q.search("content", args.query).eq("published", true)
)
.take(10);
// Deduplicate and process post results
const seenPostIds = new Set<string>();
for (const post of [...postsByTitle, ...postsByContent]) {
if (seenPostIds.has(post._id)) continue;
seenPostIds.add(post._id);
// Create snippet from content
const snippet = createSnippet(post.content, args.query, 120);
results.push({
_id: post._id,
type: "post" as const,
slug: post.slug,
title: post.title,
description: post.description,
snippet,
});
}
// Deduplicate and process page results
const seenPageIds = new Set<string>();
for (const page of [...pagesByTitle, ...pagesByContent]) {
if (seenPageIds.has(page._id)) continue;
seenPageIds.add(page._id);
// Create snippet from content
const snippet = createSnippet(page.content, args.query, 120);
results.push({
_id: page._id,
type: "page" as const,
slug: page.slug,
title: page.title,
snippet,
});
}
// Sort results: title matches first, then by relevance
const queryLower = args.query.toLowerCase();
results.sort((a, b) => {
const aInTitle = a.title.toLowerCase().includes(queryLower);
const bInTitle = b.title.toLowerCase().includes(queryLower);
if (aInTitle && !bInTitle) return -1;
if (!aInTitle && bInTitle) return 1;
return 0;
});
// Limit to top 15 results
return results.slice(0, 15);
},
});
// Helper to create a snippet around the search term
function createSnippet(
content: string,
searchTerm: string,
maxLength: number
): string {
// Remove markdown syntax for cleaner snippets
const cleanContent = content
.replace(/#{1,6}\s/g, "") // Headers
.replace(/\*\*([^*]+)\*\*/g, "$1") // Bold
.replace(/\*([^*]+)\*/g, "$1") // Italic
.replace(/`([^`]+)`/g, "$1") // Inline code
.replace(/```[\s\S]*?```/g, "") // Code blocks
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Links
.replace(/!\[([^\]]*)\]\([^)]+\)/g, "") // Images
.replace(/\n+/g, " ") // Newlines to spaces
.replace(/\s+/g, " ") // Multiple spaces to single
.trim();
const lowerContent = cleanContent.toLowerCase();
const lowerSearchTerm = searchTerm.toLowerCase();
const index = lowerContent.indexOf(lowerSearchTerm);
if (index === -1) {
// Term not found, return beginning of content
return cleanContent.slice(0, maxLength) + (cleanContent.length > maxLength ? "..." : "");
}
// Calculate start position to center the search term
const start = Math.max(0, index - Math.floor(maxLength / 3));
const end = Math.min(cleanContent.length, start + maxLength);
let snippet = cleanContent.slice(start, end);
// Add ellipsis if needed
if (start > 0) snippet = "..." + snippet;
if (end < cleanContent.length) snippet = snippet + "...";
return snippet;
}

View File

@@ -38,11 +38,12 @@ A brief description of each file in the codebase.
| File | Description |
| ---------------------- | ---------------------------------------------------------- |
| `Layout.tsx` | Page wrapper with theme toggle container |
| `Layout.tsx` | Page wrapper with search button and theme toggle |
| `ThemeToggle.tsx` | Theme switcher (dark/light/tan/cloud) |
| `PostList.tsx` | Year-grouped blog post list |
| `BlogPost.tsx` | Markdown renderer with syntax highlighting |
| `CopyPageDropdown.tsx` | Share dropdown for LLMs (ChatGPT, Claude, Cursor, VS Code) |
| `CopyPageDropdown.tsx` | Share dropdown for LLMs (ChatGPT, Claude) |
| `SearchModal.tsx` | Full text search modal with keyboard navigation |
### Context (`src/context/`)
@@ -69,6 +70,7 @@ A brief description of each file in the codebase.
| `schema.ts` | Database schema (posts, pages, viewCounts, pageViews, activeSessions) |
| `posts.ts` | Queries and mutations for blog posts, view counts |
| `pages.ts` | Queries and mutations for static pages |
| `search.ts` | Full text search queries across posts and pages |
| `stats.ts` | Real-time stats queries, page view recording, session heartbeat |
| `crons.ts` | Cron job for stale session cleanup |
| `http.ts` | HTTP endpoints: sitemap, API, Open Graph metadata |

14
package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "markdown-site",
"version": "1.0.0",
"dependencies": {
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-icons": "^1.3.2",
"convex": "^1.17.4",
"date-fns": "^3.3.1",
@@ -1003,6 +1004,19 @@
"node": ">= 8"
}
},
"node_modules/@phosphor-icons/react": {
"version": "2.1.10",
"resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz",
"integrity": "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">= 16.8",
"react-dom": ">= 16.8"
}
},
"node_modules/@radix-ui/react-icons": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz",

View File

@@ -16,6 +16,7 @@
"deploy:prod": "npx convex deploy && npm run sync:prod"
},
"dependencies": {
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-icons": "^1.3.2",
"convex": "^1.17.4",
"date-fns": "^3.3.1",

View File

@@ -85,17 +85,62 @@ useEffect(() => {
Sends a ping every 30 seconds while the page is open. This powers the "Active Now" count.
Uses refs to prevent duplicate calls and avoid write conflicts:
```typescript
const HEARTBEAT_INTERVAL_MS = 30 * 1000;
const HEARTBEAT_DEBOUNCE_MS = 5 * 1000;
// Track heartbeat state to prevent duplicate calls
const isHeartbeatPending = useRef(false);
const lastHeartbeatTime = useRef(0);
const lastHeartbeatPath = useRef<string | null>(null);
const sendHeartbeat = useCallback(
async (path: string) => {
const sessionId = sessionIdRef.current;
if (!sessionId) return;
const now = Date.now();
// Skip if heartbeat is already pending
if (isHeartbeatPending.current) {
return;
}
// Skip if same path and sent recently (debounce)
if (
lastHeartbeatPath.current === path &&
now - lastHeartbeatTime.current < HEARTBEAT_DEBOUNCE_MS
) {
return;
}
isHeartbeatPending.current = true;
lastHeartbeatTime.current = now;
lastHeartbeatPath.current = path;
try {
await heartbeatMutation({ sessionId, currentPath: path });
} catch {
// Silently fail
} finally {
isHeartbeatPending.current = false;
}
},
[heartbeatMutation],
);
useEffect(() => {
const sendHeartbeat = () => {
heartbeat({ sessionId, currentPath: path });
};
sendHeartbeat();
const intervalId = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS);
const path = location.pathname;
sendHeartbeat(path);
const intervalId = setInterval(() => {
sendHeartbeat(path);
}, HEARTBEAT_INTERVAL_MS);
return () => clearInterval(intervalId);
}, [location.pathname, heartbeat]);
}, [location.pathname, sendHeartbeat]);
```
## Backend mutations
@@ -143,9 +188,11 @@ export const recordPageView = mutation({
### heartbeat
Creates or updates a session record. Uses indexed lookup for upsert.
Creates or updates a session record. Uses indexed lookup for upsert with a 10-second dedup window to prevent write conflicts.
```typescript
const HEARTBEAT_DEDUP_MS = 10 * 1000;
export const heartbeat = mutation({
args: {
sessionId: v.string(),
@@ -153,24 +200,35 @@ export const heartbeat = mutation({
},
returns: v.null(),
handler: async (ctx, args) => {
const now = Date.now();
const existingSession = await ctx.db
.query("activeSessions")
.withIndex("by_sessionId", (q) => q.eq("sessionId", args.sessionId))
.first();
if (existingSession) {
// Early return if same path and recently updated (idempotent)
if (
existingSession.currentPath === args.currentPath &&
now - existingSession.lastSeen < HEARTBEAT_DEDUP_MS
) {
return null;
}
await ctx.db.patch(existingSession._id, {
currentPath: args.currentPath,
lastSeen: Date.now(),
});
} else {
await ctx.db.insert("activeSessions", {
sessionId: args.sessionId,
currentPath: args.currentPath,
lastSeen: Date.now(),
lastSeen: now,
});
return null;
}
await ctx.db.insert("activeSessions", {
sessionId: args.sessionId,
currentPath: args.currentPath,
lastSeen: now,
});
return null;
},
});
@@ -261,11 +319,13 @@ No manual configuration required. Sync content, and stats track it.
## Configuration constants
| Constant | Value | Location |
|----------|-------|----------|
| DEDUP_WINDOW_MS | 30 minutes | convex/stats.ts |
| SESSION_TIMEOUT_MS | 2 minutes | convex/stats.ts |
| HEARTBEAT_INTERVAL_MS | 30 seconds | src/hooks/usePageTracking.ts |
| Constant | Value | Location | Purpose |
|----------|-------|----------|---------|
| DEDUP_WINDOW_MS | 30 minutes | convex/stats.ts | Page view deduplication |
| SESSION_TIMEOUT_MS | 2 minutes | convex/stats.ts | Active session expiry |
| HEARTBEAT_DEDUP_MS | 10 seconds | convex/stats.ts | Backend idempotency window |
| HEARTBEAT_INTERVAL_MS | 30 seconds | src/hooks/usePageTracking.ts | Client heartbeat frequency |
| HEARTBEAT_DEBOUNCE_MS | 5 seconds | src/hooks/usePageTracking.ts | Frontend debounce window |
## Files involved
@@ -277,8 +337,25 @@ No manual configuration required. Sync content, and stats track it.
| `src/hooks/usePageTracking.ts` | Client-side tracking hook |
| `src/pages/Stats.tsx` | Stats page UI |
## Write conflict prevention
The stats system uses several patterns to avoid write conflicts in the `activeSessions` table:
**Backend (convex/stats.ts):**
- 10-second dedup window: skips updates if session was recently updated with same path
- Indexed queries: uses `by_sessionId` index for efficient lookups
- Early returns: mutation is idempotent and safe to call multiple times
**Frontend (src/hooks/usePageTracking.ts):**
- 5-second debounce: prevents rapid duplicate calls from the same tab
- Pending state ref: blocks overlapping async calls
- Path tracking ref: skips redundant heartbeats for same path
See `prds/howtoavoidwriteconflicts.md` for the full implementation details.
## Related documentation
- [Convex event records pattern](https://docs.convex.dev/understanding/best-practices/)
- [Preventing write conflicts](https://docs.convex.dev/error#1)
- [Optimistic concurrency control](https://docs.convex.dev/database/advanced/occ)

View File

@@ -1,8 +1,10 @@
import { ReactNode } from "react";
import { ReactNode, useState, useEffect, useCallback } from "react";
import { Link } from "react-router-dom";
import { useQuery } from "convex/react";
import { api } from "../../convex/_generated/api";
import { MagnifyingGlass } from "@phosphor-icons/react";
import ThemeToggle from "./ThemeToggle";
import SearchModal from "./SearchModal";
interface LayoutProps {
children: ReactNode;
@@ -11,10 +13,39 @@ interface LayoutProps {
export default function Layout({ children }: LayoutProps) {
// Fetch published pages for navigation
const pages = useQuery(api.pages.getAllPages);
const [isSearchOpen, setIsSearchOpen] = useState(false);
// Open search modal
const openSearch = useCallback(() => {
setIsSearchOpen(true);
}, []);
// Close search modal
const closeSearch = useCallback(() => {
setIsSearchOpen(false);
}, []);
// Handle Command+K / Ctrl+K keyboard shortcut
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Command+K on Mac, Ctrl+K on Windows/Linux
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setIsSearchOpen((prev) => !prev);
}
// Also close on Escape
if (e.key === "Escape" && isSearchOpen) {
setIsSearchOpen(false);
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isSearchOpen]);
return (
<div className="layout">
{/* Top navigation bar with page links and theme toggle */}
{/* Top navigation bar with page links, search, and theme toggle */}
<div className="top-nav">
{/* Page navigation links (optional pages like About, Projects, Contact) */}
{pages && pages.length > 0 && (
@@ -30,12 +61,25 @@ export default function Layout({ children }: LayoutProps) {
))}
</nav>
)}
{/* Search button with icon */}
<button
onClick={openSearch}
className="search-button"
aria-label="Search (⌘K)"
title="Search (⌘K)"
>
<MagnifyingGlass size={18} weight="bold" />
</button>
{/* Theme toggle */}
<div className="theme-toggle-container">
<ThemeToggle />
</div>
</div>
<main className="main-content">{children}</main>
{/* Search modal */}
<SearchModal isOpen={isSearchOpen} onClose={closeSearch} />
</div>
);
}

View File

@@ -0,0 +1,176 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "convex/react";
import { api } from "../../convex/_generated/api";
import { MagnifyingGlass, X, FileText, Article, ArrowRight } from "@phosphor-icons/react";
interface SearchModalProps {
isOpen: boolean;
onClose: () => void;
}
export default function SearchModal({ isOpen, onClose }: SearchModalProps) {
const [searchQuery, setSearchQuery] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate();
// Fetch search results from Convex
const results = useQuery(
api.search.search,
searchQuery.trim() ? { query: searchQuery } : "skip"
);
// Focus input when modal opens
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
setSearchQuery("");
setSelectedIndex(0);
}
}, [isOpen]);
// Reset selection when results change
useEffect(() => {
setSelectedIndex(0);
}, [results]);
// Handle keyboard navigation
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (!results || results.length === 0) return;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setSelectedIndex((prev) => (prev + 1) % results.length);
break;
case "ArrowUp":
e.preventDefault();
setSelectedIndex((prev) => (prev - 1 + results.length) % results.length);
break;
case "Enter":
e.preventDefault();
if (results[selectedIndex]) {
navigate(`/${results[selectedIndex].slug}`);
onClose();
}
break;
case "Escape":
e.preventDefault();
onClose();
break;
}
},
[results, selectedIndex, navigate, onClose]
);
// Handle clicking on a result
const handleResultClick = (slug: string) => {
navigate(`/${slug}`);
onClose();
};
// Handle backdrop click
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
if (!isOpen) return null;
return (
<div className="search-modal-backdrop" onClick={handleBackdropClick}>
<div className="search-modal">
{/* Search input */}
<div className="search-modal-input-wrapper">
<MagnifyingGlass size={20} className="search-modal-icon" weight="bold" />
<input
ref={inputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search posts and pages..."
className="search-modal-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
/>
<button onClick={onClose} className="search-modal-close" aria-label="Close search">
<X size={18} weight="bold" />
</button>
</div>
{/* Search results */}
<div className="search-modal-results">
{searchQuery.trim() === "" ? (
<div className="search-modal-hint">
<p>Type to search posts and pages</p>
<div className="search-modal-shortcuts">
<span className="search-shortcut">
<kbd></kbd><kbd></kbd> Navigate
</span>
<span className="search-shortcut">
<kbd></kbd> Select
</span>
<span className="search-shortcut">
<kbd>Esc</kbd> Close
</span>
</div>
</div>
) : results === undefined ? (
<div className="search-modal-loading">Searching...</div>
) : results.length === 0 ? (
<div className="search-modal-empty">
No results found for "{searchQuery}"
</div>
) : (
<ul className="search-results-list">
{results.map((result, index) => (
<li key={result._id}>
<button
className={`search-result-item ${index === selectedIndex ? "selected" : ""}`}
onClick={() => handleResultClick(result.slug)}
onMouseEnter={() => setSelectedIndex(index)}
>
<div className="search-result-icon">
{result.type === "post" ? (
<Article size={20} weight="regular" />
) : (
<FileText size={20} weight="regular" />
)}
</div>
<div className="search-result-content">
<div className="search-result-title">{result.title}</div>
<div className="search-result-snippet">{result.snippet}</div>
</div>
<div className="search-result-type">
{result.type === "post" ? "Post" : "Page"}
</div>
<ArrowRight size={16} className="search-result-arrow" weight="bold" />
</button>
</li>
))}
</ul>
)}
</div>
{/* Footer with keyboard hints */}
{results && results.length > 0 && (
<div className="search-modal-footer">
<span className="search-footer-hint">
<kbd></kbd> to select
</span>
<span className="search-footer-hint">
<kbd></kbd><kbd></kbd> to navigate
</span>
</div>
)}
</div>
</div>
);
}

View File

@@ -1137,3 +1137,378 @@ body {
grid-template-columns: 1fr;
}
}
/* Search button in top nav */
.search-button {
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 8px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition:
color 0.2s ease,
background-color 0.2s ease;
}
.search-button:hover {
color: var(--text-primary);
background-color: var(--bg-hover);
}
/* Search modal backdrop */
.search-modal-backdrop {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 15vh;
animation: backdropFadeIn 0.15s ease;
}
@keyframes backdropFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Search modal container */
.search-modal {
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 12px;
width: 100%;
max-width: 560px;
max-height: 70vh;
margin: 0 16px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2);
overflow: hidden;
display: flex;
flex-direction: column;
animation: modalSlideIn 0.2s ease;
}
@keyframes modalSlideIn {
from {
opacity: 0;
transform: translateY(-10px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* Search input wrapper */
.search-modal-input-wrapper {
display: flex;
align-items: center;
padding: 16px;
border-bottom: 1px solid var(--border-color);
gap: 12px;
}
.search-modal-icon {
color: var(--text-muted);
flex-shrink: 0;
}
.search-modal-input {
flex: 1;
background: transparent;
border: none;
outline: none;
font-size: 16px;
color: var(--text-primary);
font-family: inherit;
}
.search-modal-input::placeholder {
color: var(--text-muted);
}
.search-modal-close {
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 6px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition:
color 0.15s ease,
background-color 0.15s ease;
}
.search-modal-close:hover {
color: var(--text-primary);
background-color: var(--bg-hover);
}
/* Search results container */
.search-modal-results {
flex: 1;
overflow-y: auto;
padding: 8px;
min-height: 120px;
}
/* Search hint (empty state) */
.search-modal-hint {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 16px;
text-align: center;
}
.search-modal-hint p {
color: var(--text-muted);
font-size: 15px;
margin-bottom: 16px;
}
.search-modal-shortcuts {
display: flex;
gap: 16px;
flex-wrap: wrap;
justify-content: center;
}
.search-shortcut {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-muted);
}
.search-shortcut kbd {
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 2px 6px;
font-family: inherit;
font-size: 11px;
color: var(--text-secondary);
}
/* Loading and empty states */
.search-modal-loading,
.search-modal-empty {
display: flex;
align-items: center;
justify-content: center;
padding: 32px 16px;
color: var(--text-muted);
font-size: 15px;
}
/* Search results list */
.search-results-list {
list-style: none;
margin: 0;
padding: 0;
}
.search-result-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 12px;
background: transparent;
border: none;
border-radius: 8px;
cursor: pointer;
text-align: left;
transition: background-color 0.1s ease;
}
.search-result-item:hover,
.search-result-item.selected {
background-color: var(--bg-hover);
}
.search-result-icon {
color: var(--text-muted);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.search-result-content {
flex: 1;
min-width: 0;
overflow: hidden;
}
.search-result-title {
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
}
.search-result-snippet {
font-size: 13px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.4;
}
.search-result-type {
font-size: 11px;
color: var(--text-muted);
background-color: var(--bg-secondary);
padding: 2px 8px;
border-radius: 10px;
flex-shrink: 0;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.search-result-arrow {
color: var(--text-muted);
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s ease;
}
.search-result-item:hover .search-result-arrow,
.search-result-item.selected .search-result-arrow {
opacity: 1;
}
/* Search modal footer */
.search-modal-footer {
display: flex;
gap: 16px;
padding: 10px 16px;
border-top: 1px solid var(--border-color);
background-color: var(--bg-secondary);
}
.search-footer-hint {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-muted);
}
.search-footer-hint kbd {
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 2px 6px;
font-family: inherit;
font-size: 11px;
color: var(--text-secondary);
}
/* Dark theme adjustments for search modal */
:root[data-theme="dark"] .search-modal-backdrop {
background-color: rgba(0, 0, 0, 0.7);
}
:root[data-theme="dark"] .search-modal {
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
}
/* Tan theme adjustments for search modal */
:root[data-theme="tan"] .search-modal {
box-shadow: 0 16px 48px rgba(139, 115, 85, 0.15);
}
/* Cloud theme adjustments for search modal */
:root[data-theme="cloud"] .search-modal {
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.15);
}
/* Mobile responsive search modal */
@media (max-width: 768px) {
.search-modal-backdrop {
padding-top: 10vh;
}
.search-modal {
max-height: 80vh;
margin: 0 12px;
border-radius: 10px;
}
.search-modal-input-wrapper {
padding: 14px;
gap: 10px;
}
.search-modal-input {
font-size: 16px; /* Prevent zoom on iOS */
}
.search-modal-results {
padding: 6px;
}
.search-result-item {
padding: 10px;
gap: 10px;
}
.search-result-title {
font-size: 14px;
}
.search-result-snippet {
font-size: 12px;
}
.search-result-type {
font-size: 10px;
padding: 2px 6px;
}
.search-modal-shortcuts {
flex-direction: column;
gap: 8px;
}
.search-modal-footer {
flex-wrap: wrap;
gap: 12px;
padding: 8px 14px;
}
}
@media (max-width: 480px) {
.search-modal-backdrop {
padding-top: 5vh;
}
.search-modal {
max-height: 85vh;
}
.search-result-arrow {
display: none;
}
}