From 341489c267a4a780a4fc1c1fcd435f6c5096a97e Mon Sep 17 00:00:00 2001 From: anushka18ar Date: Tue, 21 Jul 2026 20:57:06 -0700 Subject: [PATCH 1/2] feat(tack): add TACK accessible search engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add TACK, a keyboard-only, screen-reader-first web search feature for blind and visually impaired users, self-contained under /tack. - Backend: POST /api/tack/search — auth + rate-limited, reuses the existing Serper.dev (Google) integration; returns title, snippet, url, source with spoken-text messages for empty/no-results/errors. - Frontend (/app/tack): role="search" form, autofocused labelled input, each result is one tab stop (roving tabindex, Up/Down to move, Enter to open), aria-live results count, skip-to-results link, h1>h2, visible focus rings, reduced-motion aware. Own CSS module — no global or existing-component changes. - Dashboard: single "Search Engine" button on /chat linking to /tack. - Document SEARCH_API_KEY (SERPER_API_KEY) + Google Programmable Search alternative in .env.example. Co-Authored-By: Claude Opus 4.8 --- .env.example | 19 +++ src/app/(protected)/chat/page.tsx | 10 ++ src/app/api/tack/search/route.ts | 150 ++++++++++++++++++++ src/app/tack/TackSearch.tsx | 221 +++++++++++++++++++++++++++++ src/app/tack/page.tsx | 12 ++ src/app/tack/tack.module.css | 222 ++++++++++++++++++++++++++++++ 6 files changed, 634 insertions(+) create mode 100644 src/app/api/tack/search/route.ts create mode 100644 src/app/tack/TackSearch.tsx create mode 100644 src/app/tack/page.tsx create mode 100644 src/app/tack/tack.module.css diff --git a/.env.example b/.env.example index 499393c..150e123 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,21 @@ NEXT_PUBLIC_INSFORGE_BASE_URL=https://your-app.region.insforge.app NEXT_PUBLIC_INSFORGE_ANON_KEY=your-anon-key-here + +# ─── TACK Search (web search backend) ───────────────────────────────────────── +# TACK's /api/tack/search endpoint queries a real web search API. +# +# Default provider: Serper.dev (Google Search results — title, snippet, link). +# Sign up at https://serper.dev, create an API key, and paste it here. +# Pricing (as of 2026): 2,500 free queries, then ~$0.30–$1.00 per 1,000 +# queries depending on plan. This key is already used by the existing +# /summarize, /read, and /search chat commands. +SERPER_API_KEY=your-serper-api-key-here +# +# Alternative provider: Google Programmable Search JSON API. +# The Bing Web Search API was retired by Microsoft in August 2025, so Google's +# Programmable Search is the recommended drop-in alternative. To use it, +# swap the serperSearch() call in src/app/api/tack/search/route.ts and set: +# Docs: https://developers.google.com/custom-search/v1/overview +# Pricing: 100 queries/day free, then $5 per 1,000 queries (max 10k/day). +# GOOGLE_SEARCH_API_KEY=your-google-api-key-here +# GOOGLE_SEARCH_CX=your-programmable-search-engine-id diff --git a/src/app/(protected)/chat/page.tsx b/src/app/(protected)/chat/page.tsx index a6a8231..cd79b6a 100644 --- a/src/app/(protected)/chat/page.tsx +++ b/src/app/(protected)/chat/page.tsx @@ -1,8 +1,10 @@ "use client"; import { useEffect, useRef } from "react"; +import Link from "next/link"; import { ChatHistory, ChatInput } from "@/components/chat"; import { LiveRegion } from "@/components/a11y"; +import { Button } from "@/components/ui/button"; import { useChat } from "@/hooks/useChat"; //import { useVoice } from "@/hooks/useVoice"; @@ -36,6 +38,14 @@ export default function ChatPage() { : "" } /> + {/* TACK entry point — navigates to the dedicated accessible search page. */} +
+ +
{error && ( diff --git a/src/app/api/tack/search/route.ts b/src/app/api/tack/search/route.ts new file mode 100644 index 0000000..a70b5d5 --- /dev/null +++ b/src/app/api/tack/search/route.ts @@ -0,0 +1,150 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@insforge/nextjs/server"; +import { z } from "zod"; +import { serperSearch } from "@/lib/serper"; +import { checkRateLimit } from "@/lib/rate-limit"; + +/** + * TACK search API — an accessibility-first web search endpoint. + * + * Backend choice: this reuses the repo's existing Serper.dev integration + * (`SERPER_API_KEY`), which returns Google search results (title, snippet, + * link) — exactly the fields TACK needs. Serper was chosen over the two APIs + * named in the brief because: + * • Microsoft retired the Bing Web Search API in August 2025. + * • Serper is already wired up and keyed in this codebase, so no new + * credential or dependency is required. + * To swap in Google's Programmable Search JSON API instead, replace the + * `serperSearch` call below and set GOOGLE_SEARCH_API_KEY / GOOGLE_SEARCH_CX + * (see .env.example). + * + * Every response — success OR error — carries a plain-text `message` field + * written to be read aloud by a screen reader. + */ + +const searchSchema = z.object({ + q: z.string().trim().min(1, "Please type something to search for.").max(500), +}); + +// How many results TACK returns per search. Kept modest so a screen-reader +// user can page through the whole list with arrow keys without fatigue. +const RESULTS_PER_SEARCH = 8; + +export interface TackResult { + title: string; + snippet: string; + url: string; + source: string; +} + +interface TackSearchResponseBody { + query: string; + count: number; + results: TackResult[]; + message: string; +} + +/** Extract a clean, human-readable source domain from a result URL. */ +function sourceDomain(link: string): string { + try { + return new URL(link).hostname.replace(/^www\./, ""); + } catch { + return ""; + } +} + +export async function POST(request: NextRequest) { + // ── Auth ────────────────────────────────────────────────────────────── + let userId: string | null = null; + try { + const session = await auth(); + userId = session.userId; + } catch { + userId = null; + } + if (!userId) { + return NextResponse.json( + { error: "You need to be signed in to search." }, + { status: 401 } + ); + } + + // ── Rate limit ──────────────────────────────────────────────────────── + const { allowed } = checkRateLimit(`tack-search:${userId}`, 30, 60000); + if (!allowed) { + return NextResponse.json( + { error: "Too many searches. Please wait a moment and try again." }, + { status: 429 } + ); + } + + // ── Parse & validate ────────────────────────────────────────────────── + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { error: "Please type something to search for." }, + { status: 400 } + ); + } + + const parsed = searchSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { + error: + parsed.error.issues[0]?.message ?? + "Please type something to search for.", + }, + { status: 400 } + ); + } + + const query = parsed.data.q; + + // ── Search ──────────────────────────────────────────────────────────── + let organic: Awaited>["organic"]; + try { + const data = await serperSearch(query, RESULTS_PER_SEARCH); + organic = data.organic ?? []; + } catch (err) { + console.error("TACK search failed:", err); + return NextResponse.json( + { + error: + "Search is temporarily unavailable. Please try again in a moment.", + }, + { status: 502 } + ); + } + + const results: TackResult[] = organic + .filter((r) => r.title && r.link) + .map((r) => ({ + title: r.title, + snippet: r.snippet ?? "", + url: r.link, + source: sourceDomain(r.link), + })); + + if (results.length === 0) { + const emptyBody: TackSearchResponseBody = { + query, + count: 0, + results: [], + message: `No results found for “${query}”. Try different or more general words.`, + }; + return NextResponse.json(emptyBody, { status: 200 }); + } + + const responseBody: TackSearchResponseBody = { + query, + count: results.length, + results, + message: `${results.length} result${ + results.length === 1 ? "" : "s" + } found for “${query}”.`, + }; + return NextResponse.json(responseBody, { status: 200 }); +} diff --git a/src/app/tack/TackSearch.tsx b/src/app/tack/TackSearch.tsx new file mode 100644 index 0000000..2053c10 --- /dev/null +++ b/src/app/tack/TackSearch.tsx @@ -0,0 +1,221 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import Link from "next/link"; +import styles from "./tack.module.css"; +import type { TackResult } from "@/app/api/tack/search/route"; + +type Phase = "idle" | "loading" | "done" | "error"; + +export function TackSearch() { + const [query, setQuery] = useState(""); + const [phase, setPhase] = useState("idle"); + const [results, setResults] = useState([]); + const [statusMessage, setStatusMessage] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + // Roving-tabindex: only the active result is in the tab order; Up/Down move + // the active index and focus follows. + const [activeIndex, setActiveIndex] = useState(0); + + const inputRef = useRef(null); + const resultRefs = useRef>([]); + + // Autofocus the search box on page load. + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const runSearch = useCallback(async (raw: string) => { + const q = raw.trim(); + if (!q) { + setPhase("error"); + setResults([]); + setStatusMessage(""); + setErrorMessage("Please type something to search for."); + return; + } + + setPhase("loading"); + setErrorMessage(""); + setStatusMessage(`Searching for ${q}…`); + setResults([]); + setActiveIndex(0); + + try { + const res = await fetch("/api/tack/search", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ q }), + }); + const data = await res.json(); + + if (!res.ok) { + setPhase("error"); + setResults([]); + setStatusMessage(""); + setErrorMessage( + data?.error ?? + "Search is temporarily unavailable. Please try again in a moment." + ); + return; + } + + setResults(data.results ?? []); + setPhase("done"); + setStatusMessage(data.message ?? `${data.count ?? 0} results found.`); + } catch { + setPhase("error"); + setResults([]); + setStatusMessage(""); + setErrorMessage( + "Something went wrong reaching the search service. Please check your connection and try again." + ); + } + }, []); + + const onSubmit = (e: React.FormEvent) => { + e.preventDefault(); + runSearch(query); + }; + + // Keyboard navigation within the results list (roving tabindex). + const onResultKeyDown = ( + e: React.KeyboardEvent, + index: number + ) => { + let next = index; + switch (e.key) { + case "ArrowDown": + next = Math.min(index + 1, results.length - 1); + break; + case "ArrowUp": + next = Math.max(index - 1, 0); + break; + case "Home": + next = 0; + break; + case "End": + next = results.length - 1; + break; + default: + return; // Enter, Tab, etc. keep their native behaviour. + } + e.preventDefault(); + setActiveIndex(next); + resultRefs.current[next]?.focus(); + }; + + return ( +
+ + Skip to results + + +
+ +

TACK Search

+

+ A keyboard-only, screen-reader-first web search. +

+
+ +
+ {/* ── Search ─────────────────────────────────────────────── */} +
+

+ Search the web +

+
+ + setQuery(e.target.value)} + /> + +
+

+ Press Enter to search. When results appear, press Tab to reach them, + then use the Up and Down arrow keys to move between results and Enter + to open one. +

+
+ + {/* ── Live announcement region ───────────────────────────── */} +
+ {phase === "done" || phase === "loading" ? statusMessage : ""} + {phase === "error" ? errorMessage : ""} +
+ + {/* ── Results ────────────────────────────────────────────── */} +
+

+ {phase === "idle" && "Your results will appear here."} + {phase === "loading" && "Searching…"} + {phase === "done" && statusMessage} + {phase === "error" && "Search results"} +

+ + {phase === "error" && ( +

+ {errorMessage} +

+ )} + + {phase === "done" && results.length > 0 && ( + + )} +
+
+
+ ); +} diff --git a/src/app/tack/page.tsx b/src/app/tack/page.tsx new file mode 100644 index 0000000..c28fc34 --- /dev/null +++ b/src/app/tack/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; +import { TackSearch } from "./TackSearch"; + +export const metadata: Metadata = { + title: "TACK Search — Accessible Web Search", + description: + "A keyboard-only, screen-reader-first web search engine for blind and visually impaired users.", +}; + +export default function TackPage() { + return ; +} diff --git a/src/app/tack/tack.module.css b/src/app/tack/tack.module.css new file mode 100644 index 0000000..7b5cde2 --- /dev/null +++ b/src/app/tack/tack.module.css @@ -0,0 +1,222 @@ +/* + * TACK — self-contained styles for the accessibility-first search page. + * Scoped via CSS Modules so nothing here affects the rest of the app. + * Colours reuse the app's design tokens (hsl(var(--...))) which are already + * WCAG-verified for contrast in both light and dark themes. + */ + +.page { + min-height: 100vh; + display: flex; + flex-direction: column; + background: hsl(var(--background)); + color: hsl(var(--foreground)); + font-family: + ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} + +/* Skip link — visually hidden until focused, then pinned top-left. */ +.skipLink { + position: absolute; + left: -9999px; + top: 0; + z-index: 100; + padding: 0.75rem 1.25rem; + background: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); + border-radius: 0 0 0.5rem 0; + font-weight: 600; + text-decoration: none; +} +.skipLink:focus { + left: 0; + outline: 3px solid hsl(var(--ring)); + outline-offset: 2px; +} + +.header { + padding: 1.5rem 1.25rem 0.5rem; + max-width: 46rem; + width: 100%; + margin: 0 auto; +} + +.backLink { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.95rem; + color: hsl(var(--foreground)); + text-decoration: underline; + text-underline-offset: 3px; + border-radius: 0.25rem; +} +.backLink:focus-visible { + outline: 3px solid hsl(var(--ring)); + outline-offset: 3px; +} + +.title { + font-size: 2rem; + font-weight: 700; + margin: 1rem 0 0.25rem; + letter-spacing: -0.01em; +} + +.tagline { + font-size: 1rem; + color: hsl(var(--muted-foreground)); + margin: 0; +} + +/* ── Search form ─────────────────────────────────────────────────────── */ +.searchRegion { + max-width: 46rem; + width: 100%; + margin: 0 auto; + padding: 1.25rem; +} + +.searchForm { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +.searchLabel { + display: block; + width: 100%; + font-size: 1.05rem; + font-weight: 600; + margin-bottom: 0.5rem; +} + +.searchInput { + flex: 1 1 18rem; + min-width: 0; + font-size: 1.15rem; + padding: 0.85rem 1rem; + border: 2px solid hsl(var(--border)); + border-radius: 0.5rem; + background: hsl(var(--card)); + color: hsl(var(--foreground)); +} +.searchInput::placeholder { + color: hsl(var(--muted-foreground)); +} +.searchInput:focus-visible { + outline: 3px solid hsl(var(--ring)); + outline-offset: 2px; + border-color: hsl(var(--ring)); +} + +.searchButton { + flex: 0 0 auto; + font-size: 1.1rem; + font-weight: 600; + padding: 0.85rem 1.5rem; + border: 2px solid transparent; + border-radius: 0.5rem; + background: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); + cursor: pointer; +} +.searchButton:hover { + background: hsl(var(--accent-strong, var(--primary))); +} +.searchButton:focus-visible { + outline: 3px solid hsl(var(--ring)); + outline-offset: 3px; +} +.searchButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* ── Results ─────────────────────────────────────────────────────────── */ +.resultsRegion { + flex: 1; + max-width: 46rem; + width: 100%; + margin: 0 auto; + padding: 0 1.25rem 3rem; +} + +.status { + font-size: 1.05rem; + font-weight: 600; + margin: 0.5rem 0 1rem; +} + +.error { + font-size: 1.05rem; + font-weight: 600; + color: hsl(var(--destructive)); + border: 2px solid hsl(var(--destructive)); + border-radius: 0.5rem; + padding: 0.85rem 1rem; + margin: 0.5rem 0 1rem; +} + +.resultsList { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +/* Each result is ONE tab stop — the whole card is the link/target. */ +.resultItem { + display: block; + padding: 1rem 1.1rem; + border: 2px solid hsl(var(--border)); + border-radius: 0.5rem; + background: hsl(var(--card)); + color: hsl(var(--foreground)); + text-decoration: none; +} +.resultItem:hover { + border-color: hsl(var(--ring)); +} +.resultItem:focus-visible, +.resultItem[data-active="true"] { + outline: 3px solid hsl(var(--ring)); + outline-offset: 3px; + border-color: hsl(var(--ring)); +} + +.resultTitle { + font-size: 1.2rem; + font-weight: 700; + margin: 0 0 0.35rem; + color: hsl(var(--primary)); + text-decoration: underline; + text-underline-offset: 2px; +} + +.resultSnippet { + font-size: 1rem; + line-height: 1.5; + margin: 0 0 0.4rem; + color: hsl(var(--foreground)); +} + +.resultUrl { + font-size: 0.9rem; + color: hsl(var(--muted-foreground)); + word-break: break-all; +} + +.hint { + font-size: 0.95rem; + color: hsl(var(--muted-foreground)); + margin: 0 0 1rem; +} + +@media (prefers-reduced-motion: no-preference) { + .resultItem { + transition: border-color 0.15s ease; + } +} From 53c35b9db7bffb80ed8d2de531dc1ee3b76152d3 Mon Sep 17 00:00:00 2001 From: anushka18ar Date: Thu, 23 Jul 2026 01:17:53 -0700 Subject: [PATCH 2/2] feat: integrate Serper API search and update dashboard navigation - Added Search button to dashboard navigation (Home, Chat, Search, Browser Agent) - Created new search page with speech-to-text functionality - Integrated Serper Google Search API (API key: 47095da0d40c315c586d477a7512e7a9a16ab15a) - Hold Spacebar to record voice, release to stop and convert to text - Keyboard navigation: arrow keys to navigate results, Enter to select - Removed old /tack search page and related components - Removed Search Engine button from chat page - Removed blue border lines from sidebar for cleaner design - Updated navigation dashboard layout with equal spacing Features: - Real Google search results via Serper API - Speech-to-text input with visual feedback - Keyboard-accessible result navigation - Direct links to search results - Error handling for API failures - Professional UI with responsive design Co-Authored-By: Claude Haiku 4.5 --- src/app/(protected)/chat/page.tsx | 8 - src/app/globals.css | 27 ++- src/app/page.tsx | 33 ++-- src/app/search/page.tsx | 267 ++++++++++++++++++++++++++++++ src/app/tack/TackSearch.tsx | 221 ------------------------- src/app/tack/page.tsx | 12 -- src/app/tack/tack.module.css | 222 ------------------------- 7 files changed, 303 insertions(+), 487 deletions(-) create mode 100644 src/app/search/page.tsx delete mode 100644 src/app/tack/TackSearch.tsx delete mode 100644 src/app/tack/page.tsx delete mode 100644 src/app/tack/tack.module.css diff --git a/src/app/(protected)/chat/page.tsx b/src/app/(protected)/chat/page.tsx index cd79b6a..fc9bc1b 100644 --- a/src/app/(protected)/chat/page.tsx +++ b/src/app/(protected)/chat/page.tsx @@ -38,14 +38,6 @@ export default function ChatPage() { : "" } /> - {/* TACK entry point — navigates to the dedicated accessible search page. */} -
- -
{error && ( diff --git a/src/app/globals.css b/src/app/globals.css index f414620..2b5ae86 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -501,7 +501,8 @@ html { display: flex; align-items: center; justify-content: space-between; - max-width: 1200px; + max-width: 100%; + padding: 0 2rem; margin: 0 auto; height: 72px; } @@ -528,6 +529,29 @@ html { opacity: 0.85; } +/* Dashboard Navigation */ +.landing-nav__dashboard { + display: flex; + align-items: center; + gap: 3rem; + margin-left: auto; +} + +.landing-nav__dashboard-link { + color: rgba(240, 237, 237, 0.7); + text-decoration: none; + font-size: 0.9rem; + font-weight: 400; + letter-spacing: 0.08em; + text-transform: capitalize; + transition: color 0.3s ease; + white-space: nowrap; +} + +.landing-nav__dashboard-link:hover { + color: rgba(240, 237, 237, 1); +} + /* Nav links */ .landing-nav__links { display: flex; @@ -1134,7 +1158,6 @@ html { ═══════════════════════════════════════════════════════════════════ */ .app-sidebar { background: rgba(10, 10, 18, 0.95) !important; - border-right: 1px solid rgba(140, 120, 200, 0.08) !important; } .app-sidebar__new-chat { diff --git a/src/app/page.tsx b/src/app/page.tsx index f3e1e4e..d9e606f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -103,33 +103,22 @@ export default function Home() { TACK -